diff --git a/windows/src/Murmur.App/App.axaml.cs b/windows/src/Murmur.App/App.axaml.cs index 40e0861..33ac969 100644 --- a/windows/src/Murmur.App/App.axaml.cs +++ b/windows/src/Murmur.App/App.axaml.cs @@ -52,6 +52,7 @@ public App() private DictationController? _controller; private readonly SemaphoreSlim _controllerGate = new(1, 1); + private bool _recognitionBlocked; private int _controllerGeneration; private volatile bool _shuttingDown; private Action? _unsubscribeControllerEvents; @@ -332,11 +333,23 @@ private void OnProfileMenuItemClick(object? sender, EventArgs e) private async Task StartControllerAsync() { + if (_recognitionBlocked || _controller?.RequiresRestart == true) + { + _recognitionBlocked = true; + _settingsWindow?.SetStatus(UiText.Text("RecognitionRestartRequired"), isError: true); + _hud?.UpdateState(DictationState.ErrorState(UiText.Text("RecognitionRestartRequired")), 0, _recoveryTranscript ?? string.Empty); + return; + } var generation = Interlocked.Increment(ref _controllerGeneration); var settings = _settings.Normalize(); var model = ModelManifest.Get(settings.SpeechModelId); bool IsCurrentLoad() => !_shuttingDown && generation == Volatile.Read(ref _controllerGeneration); - await _controllerGate.WaitAsync(); + if (!await _controllerGate.WaitAsync(TimeSpan.FromSeconds(3))) + { + _recognitionBlocked = true; + _settingsWindow?.SetStatus(UiText.Text("RecognitionRestartRequired"), isError: true); + return; + } ITranscriber? transcriber = null; WasapiAudioCapture? audioCapture = null; LowLevelKeyboardHook? hotkeyHook = null; @@ -344,7 +357,7 @@ private async Task StartControllerAsync() var loadingTranscriber = false; try { - if (!IsCurrentLoad()) return; + if (!IsCurrentLoad() || _recognitionBlocked) return; _profileToggleHook?.Dispose(); _profileToggleHook = null; await DisposeControllerAsync(); @@ -491,8 +504,26 @@ private async Task DisposeControllerAsync() _controller = null; _controllerModelId = null; _settingsWindow?.SetModelNotLoaded(); - try { if (owner != null) await owner.DisposeAsync(); } - finally { unsubscribe?.Invoke(); } + try + { + if (owner != null) await owner.DisposeAsync(); + } + catch (TimeoutException ex) + { + _recognitionBlocked = true; + throw new InvalidOperationException(UiText.Text("RecognitionRestartRequired"), ex); + } + finally + { + _recognitionBlocked |= owner?.RequiresRestart == true; + if (owner?.RequiresRestart == true && !string.IsNullOrWhiteSpace(owner.CurrentTranscript)) + { + _recoveryTranscript = owner.CurrentTranscript; + _settingsWindow?.SetRecoveryTranscript(_recoveryTranscript); + } + unsubscribe?.Invoke(); + } + if (_recognitionBlocked) throw new InvalidOperationException(UiText.Text("RecognitionRestartRequired")); } private Action SubscribeControllerEvents(DictationController owner, string modelId) @@ -513,7 +544,7 @@ void Post(Action action) Post(() => { AppLogger.Log($"StateChanged: {state.Status}"); - if (state.Status == DictationStatus.Error && !string.IsNullOrWhiteSpace(text)) + if (state.Status == DictationStatus.Error && state.Message != "RecordingDurationLimit" && !string.IsNullOrWhiteSpace(text)) { _recoveryTranscript = text; _settingsWindow?.SetRecoveryTranscript(text); @@ -594,6 +625,10 @@ private static DictationState LocalizeDictationState(DictationState state) "TextTargetUnavailable" => UiText.TextTargetUnavailable, "TextTargetChanged" => UiText.TextTargetChanged, "TextModifierHeld" => UiText.TextModifierHeld, + "RecognitionRestartRequired" => UiText.Text("RecognitionRestartRequired"), + "StreamingBacklogExceeded" => UiText.Text("StreamingBacklogExceeded"), + "StreamingRecognitionFailed" => UiText.Text("StreamingRecognitionFailed"), + "RecordingDurationLimit" => UiText.Text("RecordingDurationLimit"), _ => state.Message }; return string.Equals(localized, state.Message, StringComparison.Ordinal) @@ -966,6 +1001,11 @@ private void OnSettingsDownloadCancelRequested(object? sender, EventArgs e) private async void OnSettingsMicrophoneTestRequested() { if (_shuttingDown) return; + if (_recognitionBlocked || _controller?.RequiresRestart == true) + { + _settingsWindow?.SetStatus(UiText.Text("RecognitionRestartRequired"), isError: true); + return; + } bool isRunning; lock (_testCaptureLock) { @@ -1045,17 +1085,19 @@ private async Task StopMicrophoneTestAsync(int? expectedGeneration = null) { if (capture != null) { - try + var actualStop = Task.Run(async () => { - await capture.StopAsync().ConfigureAwait(false); - } + try { await capture.StopAsync(CancellationToken.None).ConfigureAwait(false); } + finally { if (!capture.IsRunning) capture.Dispose(); } + }); + ShutdownDeadline.Observe(actualStop); + try { await actualStop.WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false); } catch (Exception ex) { + _recognitionBlocked = true; AppLogger.LogException("StopMicrophoneTestAsync", ex); - } - finally - { - capture.Dispose(); + Dispatcher.UIThread.Post(() => _settingsWindow?.SetStatus( + UiText.Text("RecognitionRestartRequired"), isError: true)); } } } @@ -1277,6 +1319,16 @@ private async void OnExitClick(object? sender, EventArgs e) AppLogger.Log("App exiting via Tray Exit."); _updateCts.Cancel(); _downloadCts?.Cancel(); + await ShutdownDeadline.RunAsync(ShutdownCoreAsync(), () => + { + _hud?.Close(); + _settingsWindow?.Close(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) desktop.Shutdown(); + }, () => Environment.Exit(0)); + } + + private async Task ShutdownCoreAsync() + { if (_initializationTask != null) await _initializationTask; await StopMicrophoneTestAsync(); await _controllerGate.WaitAsync(); @@ -1286,18 +1338,10 @@ private async void OnExitClick(object? sender, EventArgs e) _profileToggleHook = null; await DisposeControllerAsync(); } - catch (Exception ex) { AppLogger.LogException("Controller shutdown", ex); } finally { _controllerGate.Release(); } _updateChecker.Dispose(); _updateCts.Dispose(); _recoveryTranscript = null; _clickSoundPlayer.Dispose(); - _hud?.Close(); - _settingsWindow?.Close(); - - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - desktop.Shutdown(); - } } } diff --git a/windows/src/Murmur.App/Controls/VUMeterControl.cs b/windows/src/Murmur.App/Controls/VUMeterControl.cs index e5e746a..12e749d 100644 --- a/windows/src/Murmur.App/Controls/VUMeterControl.cs +++ b/windows/src/Murmur.App/Controls/VUMeterControl.cs @@ -3,12 +3,12 @@ using Avalonia.Controls; using Avalonia.Media; using Avalonia.Threading; +using System.Diagnostics; namespace Murmur.App.Controls; /// -/// Analog VU meter with damped spring ballistics (300ms attack, 420ms release, 6% overshoot) -/// and discrete 16-segment instrumentation bar. +/// Sixteen-segment meter with studio HUD envelope or legacy settings spring ballistics. /// public sealed class VUMeterControl : Control { @@ -55,12 +55,28 @@ public bool UseHudPalette private double _position; private double _velocity; + private double _holdRemaining; + private long _lastTick; + private bool _reducedMotion; private DispatcherTimer? _timer; private bool _isAttached; private int _lastReportedLevelBucket = -1; /// Internal flag indicating if animation timer is running (for tests only). internal bool IsAnimationRunning => _timer?.IsEnabled == true; + internal double CurrentPosition => _position; + internal double HoldRemainingSeconds => _holdRemaining; + + internal bool ReducedMotion + { + get => _reducedMotion; + set + { + if (_reducedMotion == value) return; + _reducedMotion = value; + OnLevelOrActiveChanged(); + } + } internal const int SegmentCount = 16; private static readonly IBrush UnlitBrush = new SolidColorBrush(Color.Parse("#262422")); @@ -121,6 +137,9 @@ private double ResolveDouble(string key, double fallback) private void OnPaletteModeChanged() { _paletteDirty = true; + _holdRemaining = 0; + _velocity = 0; + EvaluateTimerState(); InvalidateVisual(); } @@ -204,7 +223,7 @@ private void OnLevelOrActiveChanged() { UpdateItemStatus(); EvaluateTimerState(); - if (!_isAttached || !IsVisible) + if (!_isAttached || !IsVisible || (UseHudPalette && ReducedMotion)) { InvalidateVisual(); } @@ -223,6 +242,15 @@ private void UpdateItemStatus() private void EvaluateTimerState() { + if (UseHudPalette && ReducedMotion) + { + StopTimer(); + _position = IsActive ? Math.Clamp((double)Level, 0, 1) : 0; + _velocity = 0; + _holdRemaining = 0; + return; + } + if (!_isAttached || !IsVisible) { StopTimer(); @@ -230,7 +258,9 @@ private void EvaluateTimerState() } var target = IsActive ? Math.Clamp((double)Level, 0.0, 1.0) : 0.0; - var needsAnimation = IsActive || _position > 0.001 || Math.Abs(target - _position) > 0.001 || Math.Abs(_velocity) > 0.0001; + var needsAnimation = UseHudPalette + ? Math.Abs(target - _position) > 0.001 + : IsActive || _position > 0.001 || Math.Abs(target - _position) > 0.001 || Math.Abs(_velocity) > 0.0001; if (needsAnimation) { @@ -248,13 +278,19 @@ private void StartTimer() { _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(1000.0 / 60.0), DispatcherPriority.Render, (_, _) => { - AdvanceNeedle(); + var now = Stopwatch.GetTimestamp(); + if (UseHudPalette) + StepBallistics(Stopwatch.GetElapsedTime(_lastTick, now).TotalSeconds); + else + AdvanceNeedle(); + _lastTick = now; InvalidateVisual(); }); } if (!_timer.IsEnabled) { + _lastTick = Stopwatch.GetTimestamp(); _timer.Start(); } } @@ -297,6 +333,47 @@ private void AdvanceNeedle() } } + internal void ResetBallistics() + { + StopTimer(); + _position = _velocity = _holdRemaining = 0; + InvalidateVisual(); + } + + internal void StepBallistics(double dtSeconds) + { + if (!double.IsFinite(dtSeconds) || dtSeconds <= 0) return; + var target = IsActive ? Math.Clamp((double)Level, 0, 1) : 0; + if (ReducedMotion) + { + _position = target; + _holdRemaining = 0; + StopTimer(); + return; + } + + var rising = target >= _position; + if (rising) + { + _holdRemaining = .040; + } + else + { + if (!IsActive) _holdRemaining = 0; + var heldTime = Math.Min(_holdRemaining, dtSeconds); + _holdRemaining -= heldTime; + dtSeconds -= heldTime; + } + + // The controller already smooths input; keep the HUD's extra response short and monotone. + _position = target + (_position - target) * Math.Exp(-3 * dtSeconds / (rising ? .035 : .160)); + if (Math.Abs(target - _position) < .001) + { + _position = target; + StopTimer(); + } + } + /// public override void Render(DrawingContext context) { diff --git a/windows/src/Murmur.App/Diagnostics/ShutdownDeadline.cs b/windows/src/Murmur.App/Diagnostics/ShutdownDeadline.cs new file mode 100644 index 0000000..d6655c2 --- /dev/null +++ b/windows/src/Murmur.App/Diagnostics/ShutdownDeadline.cs @@ -0,0 +1,26 @@ +namespace Murmur.App.Diagnostics; + +internal static class ShutdownDeadline +{ + internal static void Observe(Task work) => _ = work.ContinueWith( + static completed => { _ = completed.Exception; }, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + + internal static async Task RunAsync(Task cleanup, Action completed, Action forceExit, + Func? wait = null) + { + Observe(cleanup); + try + { + wait ??= static (work, budget) => work.WaitAsync(budget); + await wait(cleanup, TimeSpan.FromSeconds(3)); + } + catch + { + // Only process termination can reclaim a genuinely stuck native call safely. + forceExit(); + return; + } + completed(); + } +} diff --git a/windows/src/Murmur.App/Localization/UiTextResources.de.resx b/windows/src/Murmur.App/Localization/UiTextResources.de.resx index 63a67e3..13b3981 100644 --- a/windows/src/Murmur.App/Localization/UiTextResources.de.resx +++ b/windows/src/Murmur.App/Localization/UiTextResources.de.resx @@ -215,4 +215,8 @@ z. B. GitHub z. B. cloud code Bestätigen + Die Spracherkennung konnte nicht sicher beendet werden. Bitte Murmur vor dem nächsten Diktat oder Modellwechsel neu starten. Geretteter Teiltext ist unvollständig. + Erkennung kam nicht hinterher. Aufnahme gestoppt; wiederhergestellter Text ist unvollständig. Bitte kürzer diktieren. + Streaming-Erkennung fehlgeschlagen. Aufnahme gestoppt; wiederhergestellter Text ist unvollständig. Bitte erneut versuchen. + Fünf-Minuten-Grenze erreicht. Aufnahme automatisch gestoppt. Ergebnis prüfen; danach gesprochene Wörter wurden nicht aufgenommen. diff --git a/windows/src/Murmur.App/Localization/UiTextResources.resx b/windows/src/Murmur.App/Localization/UiTextResources.resx index 1c230e3..692237e 100644 --- a/windows/src/Murmur.App/Localization/UiTextResources.resx +++ b/windows/src/Murmur.App/Localization/UiTextResources.resx @@ -215,4 +215,8 @@ e.g. GitHub e.g. cloud code Confirm + Speech recognition did not finish safely. Please restart Murmur before dictating or changing models. Any recovered partial text is incomplete. + Recognition could not keep up. Recording stopped; recovered text is incomplete. Please try a shorter dictation. + Streaming recognition failed. Recording stopped; recovered text is incomplete. Please try again. + Five-minute limit reached. Recording stopped automatically. Check the result; words spoken after stopping were not recorded. diff --git a/windows/src/Murmur.App/Styles/MurmurTokens.axaml b/windows/src/Murmur.App/Styles/MurmurTokens.axaml index 59a38f4..7e02336 100644 --- a/windows/src/Murmur.App/Styles/MurmurTokens.axaml +++ b/windows/src/Murmur.App/Styles/MurmurTokens.axaml @@ -87,6 +87,11 @@ + + + + + @@ -209,7 +214,12 @@ - + + + + + + @@ -317,7 +327,7 @@ 245 416 301 - 38 + 44 488 83 288 @@ -336,8 +346,8 @@ 0.96 1 22 - 26 - 19 + 22 + 22 14 12,8 1 @@ -872,6 +882,21 @@ + + + + + + diff --git a/windows/src/Murmur.App/Views/HudWindow.axaml b/windows/src/Murmur.App/Views/HudWindow.axaml index 597e636..dc01594 100644 --- a/windows/src/Murmur.App/Views/HudWindow.axaml +++ b/windows/src/Murmur.App/Views/HudWindow.axaml @@ -24,10 +24,53 @@ HorizontalAlignment="Center" VerticalAlignment="Center" Background="{DynamicResource HudChassisBrush}" - BorderBrush="{DynamicResource HudHairlineBrush}" + BorderBrush="{DynamicResource HudSpecularHairlineBrush}" BorderThickness="{DynamicResource HudBorderThickness}" CornerRadius="{DynamicResource HudOrbRadius}" - RenderTransformOrigin="50%,50%" /> + RenderTransformOrigin="50%,50%"> + + + + + + + + + + + + + + + + + + + + + + + + + - + + + Reports whether Windows animation effects are off; replaceable in tests. + internal Func IsReducedMotion { get; set; } = ReadSystemReducedMotion; + + /// True while the rainbow rim is requesting animation frames. + internal bool IsRainbowAnimating => _rainbowAnimating; /// Initializes a new instance of HudWindow. public HudWindow() { InitializeComponent(); + _rainbowBrush = (ConicGradientBrush)RainbowRim.Resources["HudRainbowBrush"]!; ApplyPlacementAlignment(); LayoutUpdated += OnLayoutUpdated; } @@ -93,14 +110,77 @@ protected override void OnOpened(EventArgs e) protected override void OnClosed(EventArgs e) { base.OnClosed(e); - CancelContentReveal(); - StopProcessingAnimation(); - _hideTimer?.Stop(); + StopHiddenEffects(); LayoutUpdated -= OnLayoutUpdated; _appliedNativeRegion = null; _platformHandle = null; } + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == IsVisibleProperty && HudMeter is not null) + { + HudMeter.IsVisible = IsVisible; + if (!IsVisible) StopHiddenEffects(); + } + } + + private void StopHiddenEffects() + { + _recordingAnimating = false; + _recordingPulseStart = null; + _entryGeneration++; + CancelContentReveal(); + CancelPendingHide(); + HudSurface.Classes.Set("motionOff", true); + HudContentSurface.Classes.Set("motionOff", true); + TranscriptText.Classes.Set("motionOff", true); + RecordingRim.Classes.Set("motionOff", true); + RainbowRim.Classes.Set("motionOff", true); + RecordingRim.Classes.Set("active", false); + StopProcessingAnimation(); + HudMeter.ResetBallistics(); + } + + private bool ApplyMotionPreference(DictationStatus status) + { + var reduced = IsReducedMotion(); + var changed = reduced != _reducedMotion; + _reducedMotion = reduced; + HudMeter.ReducedMotion = reduced; + var instant = reduced || status == DictationStatus.Error; + HudSurface.Classes.Set("motionOff", instant); + HudContentSurface.Classes.Set("motionOff", instant); + TranscriptText.Classes.Set("motionOff", instant); + if (reduced) + { + _entryGeneration++; + CancelContentReveal(); + HudSurface.Opacity = 1; + HudSurface.RenderTransform = TransformScale1; + SetContentVisible(status != DictationStatus.Idle); + } + var instantRims = reduced || status is DictationStatus.Starting or DictationStatus.Error; + RecordingRim.Classes.Set("motionOff", instantRims); + RainbowRim.Classes.Set("motionOff", instantRims); + RecordingRim.Classes.Set("active", status == DictationStatus.Listening); + var pulse = status == DictationStatus.Listening && !reduced; + if (_recordingAnimating != pulse) _recordingPulseStart = null; + _recordingAnimating = pulse; + if (pulse) + { + RequestRimFrame(); + } + else if (status != DictationStatus.Finishing || reduced) + { + RecordingStroke.Opacity = 1; + RecordingWash.Opacity = .30; + } + return changed; + } + private void OnLayoutUpdated(object? sender, EventArgs e) { ApplyNativeRegion(); @@ -183,7 +263,8 @@ private void PositionForForegroundWindow() private IBrush GetBrush(string key, IBrush fallback) { - if (this.TryFindResource(key, out var resource) && resource is IBrush brush) + if ((this.TryFindResource(key, ActualThemeVariant, out var resource) || + this.TryFindResource(key, out resource)) && resource is IBrush brush) { return brush; } @@ -307,6 +388,11 @@ private void CancelContentReveal() private void ScheduleContentReveal() { CancelContentReveal(); + if (_reducedMotion) + { + SetContentVisible(true); + return; + } var timer = new DispatcherTimer { @@ -339,7 +425,11 @@ private void StartProcessingAnimation() { ProcessingDots.IsVisible = true; _processingPhase = 0; + ProcessingDot1.Opacity = ProcessingDot2.Opacity = ProcessingDot3.Opacity = 0.35; _processingTimer?.Stop(); + _processingTimer = null; + StartRainbowRim(); + if (_reducedMotion) return; _processingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds( @@ -363,10 +453,80 @@ private static void SetDotOpacity(Ellipse dot, double phase, double offset) dot.Opacity = 0.35 + (pulse * 0.65); } + private void StartRainbowRim() + { + RainbowRim.Classes.Set("active", true); + _rainbowAnimating = !_reducedMotion; + if (_rainbowAnimating) + { + RequestRimFrame(); + } + else + { + // Reduced motion: calm, static rainbow. + _rainbowBrush.Angle = RainbowRestAngle; + } + } + + private void StopRainbowRim() + { + _rainbowAnimating = false; + RainbowRim.Classes.Set("active", false); + } + + private void RequestRimFrame() + { + if (_rimFrameRequested) return; + _rimFrameRequested = true; + RequestAnimationFrame(OnRimFrame); + } + + private void OnRimFrame(TimeSpan time) + { + _rimFrameRequested = false; + if (!IsVisible || (!_rainbowAnimating && !_recordingAnimating)) + { + _rainbowAnimating = false; + _recordingAnimating = false; + return; + } + + AdvanceRimVisuals(time); + RequestRimFrame(); + } + + internal void AdvanceRimVisuals(TimeSpan time) + { + if (_recordingAnimating) + { + _recordingPulseStart ??= time; + var phase = (time - _recordingPulseStart.Value).TotalSeconds / 1.2; + var light = (1 + Math.Cos(phase * 2 * Math.PI)) / 2; + RecordingStroke.Opacity = .55 + .45 * light; + RecordingWash.Opacity = .12 + .18 * light; + } + if (_rainbowAnimating) + _rainbowBrush.Angle = RainbowRestAngle + (time.TotalSeconds * RainbowDegreesPerSecond % 360); + } + + private static bool ReadSystemReducedMotion() + { + try + { + return Win32.SystemParametersInfoW(Win32.SPI_GETCLIENTAREAANIMATION, 0, out var enabled, 0) + && !enabled; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return false; + } + } + private void StopProcessingAnimation() { _processingTimer?.Stop(); _processingTimer = null; + StopRainbowRim(); ProcessingDots.IsVisible = false; ProcessingDot1.Opacity = 0.35; ProcessingDot2.Opacity = 0.35; @@ -411,6 +571,7 @@ private static string BuildErrorText(string? message, string? transcript) private void BeginOrbEntry() { + var generation = ++_entryGeneration; ApplyPlacementAlignment(); CancelContentReveal(); @@ -423,12 +584,12 @@ private void BeginOrbEntry() diameter, diameter, orbRadius, - GetBrush("HudHairlineBrush", Brushes.Gray)); + GetBrush("HudSpecularHairlineBrush", Brushes.Gray)); SetContentVisible(false); - HudSurface.Opacity = 0; - HudSurface.RenderTransform = TransformScaleOrbStart; + HudSurface.Opacity = _reducedMotion ? 1 : 0; + HudSurface.RenderTransform = _reducedMotion ? TransformScale1 : TransformScaleOrbStart; if (!IsVisible) { @@ -436,10 +597,16 @@ private void BeginOrbEntry() Show(); } + if (_reducedMotion) + { + SetContentVisible(true); + return; + } + Dispatcher.UIThread.Post(() => { - if (!IsVisible || - _lastStatus == DictationStatus.Idle) + if (generation != _entryGeneration || !IsVisible || + _lastStatus is DictationStatus.Idle or DictationStatus.Error) { return; } @@ -460,6 +627,13 @@ private void CancelPendingHide() private void ScheduleHide() { + CancelPendingHide(); + if (_reducedMotion) + { + SetContentVisible(false); + Hide(); + return; + } var generation = _hideGeneration; var hideTimer = new DispatcherTimer { @@ -514,7 +688,11 @@ public void RefreshLocalizedLabels() break; case DictationStatus.Finishing: TelemetryBadge.Text = UiText.HudProc; - if (string.IsNullOrWhiteSpace(_lastTranscript)) + if (!string.IsNullOrWhiteSpace(_lastErrorMessage)) + { + UpdateTranscriptText(_lastErrorMessage); + } + else if (string.IsNullOrWhiteSpace(_lastTranscript)) { UpdateTranscriptText(UiText.HudTranscribing); } @@ -533,6 +711,7 @@ public void UpdateLevel(DictationStatus status, float level) { if (Dispatcher.UIThread.CheckAccess()) { + if (!IsVisible || status != _lastStatus) return; HudMeter.Level = Math.Clamp(level, 0.0f, 1.0f); HudMeter.IsActive = status == DictationStatus.Listening; return; @@ -546,6 +725,7 @@ public void UpdateLevel(DictationStatus status, float level) var lvl = _pendingLevel; var st = _pendingLevelStatus; Interlocked.Exchange(ref _levelUpdatePending, 0); + if (!IsVisible || st != _lastStatus) return; HudMeter.Level = Math.Clamp(lvl, 0.0f, 1.0f); HudMeter.IsActive = st == DictationStatus.Listening; @@ -560,13 +740,17 @@ public void UpdateState(DictationState state, float level, string transcript) void ApplyState() { + var motionChanged = ApplyMotionPreference(state.Status); HudMeter.Level = Math.Clamp(level, 0.0f, 1.0f); HudMeter.IsActive = state.Status == DictationStatus.Listening; + if (state.Status is not (DictationStatus.Listening or DictationStatus.Finishing)) + HudMeter.ResetBallistics(); if (state.Status == DictationStatus.Idle) { CancelContentReveal(); StopProcessingAnimation(); + _entryGeneration++; _lastStatus = DictationStatus.Idle; _lastTranscript = null; @@ -577,7 +761,7 @@ void ApplyState() GetDouble("HudOrbDiameter", 52), GetDouble("HudArmedHeight", 44), GetCornerRadius("HudOrbRadius", new CornerRadius(26)), - GetBrush("HudHairlineBrush", Brushes.Gray)); + GetBrush("HudSpecularHairlineBrush", Brushes.Gray)); SetContentTarget( GetDouble("HudArmedWidth", 245), GetDouble("HudArmedHeight", 44)); @@ -605,7 +789,7 @@ void ApplyState() _lastStatus is null || _lastStatus == DictationStatus.Idle; - if (!statusChanged && !transcriptChanged && !errorChanged && !entering) + if (!statusChanged && !transcriptChanged && !errorChanged && !entering && !motionChanged) { return; } @@ -616,9 +800,8 @@ _lastStatus is null || var idleLamp = GetBrush("HudIdleLampBrush", Brushes.DarkGray); var recordingEdge = GetBrush("HudRecordingEdgeBrush", Brushes.Red); - var processingBrush = GetBrush("HudProcessingBrush", Brushes.DodgerBlue); var errorBrush = GetBrush("HudErrorBrush", Brushes.DarkOrange); - var hairlineBrush = GetBrush("HudHairlineBrush", Brushes.Gray); + var hairlineBrush = GetBrush("HudSpecularHairlineBrush", Brushes.Gray); if (entering) { @@ -644,7 +827,7 @@ _lastStatus is null || case DictationStatus.Listening: StopProcessingAnimation(); - RecordLamp.Fill = GetBrush("RecordLampBrush", recordingEdge); + RecordLamp.Fill = recordingEdge; TelemetryBadge.Text = UiText.Text("HudRecording"); var preview = BuildTranscriptTail(transcript, 15); UpdateTranscriptText(string.IsNullOrEmpty(preview) ? UiText.HudListening : preview); @@ -663,13 +846,13 @@ _lastStatus is null || GetDouble("HudRecordingMaxWidth", 416)); var recordingHeight = GetDouble("HudArmedHeight", 44); - var recordingRadius = GetCornerRadius("HudRadiusRecording", new CornerRadius(26)); + var recordingRadius = GetCornerRadius("HudRadiusRecording", new CornerRadius(22)); var widthStep = GetDouble("HudTranscriptWidthStep", 12); if (_recordingWidth <= 0 || Math.Abs(desiredWidth - _recordingWidth) >= widthStep) { _recordingWidth = desiredWidth; - SetSurfaceTarget(_recordingWidth, recordingHeight, recordingRadius, recordingEdge); + SetSurfaceTarget(_recordingWidth, recordingHeight, recordingRadius, hairlineBrush); SetContentTarget(_recordingWidth, recordingHeight); } break; @@ -682,26 +865,26 @@ _lastStatus is null || preview = BuildTranscriptTail(transcript, 15); UpdateTranscriptText( - string.IsNullOrWhiteSpace(preview) + !string.IsNullOrWhiteSpace(state.Message) ? state.Message : string.IsNullOrWhiteSpace(preview) ? UiText.HudTranscribing : preview); - if (statusChanged) + if (statusChanged || entering || motionChanged) { StartProcessingAnimation(); } SetSurfaceTarget( GetDouble("HudProcessingWidth", 301), - GetDouble("HudProcessingHeight", 38), + GetDouble("HudProcessingHeight", 44), GetCornerRadius( "HudRadiusProcessing", - new CornerRadius(19)), - processingBrush); + new CornerRadius(22)), + hairlineBrush); SetContentTarget( GetDouble("HudProcessingWidth", 301), - GetDouble("HudProcessingHeight", 38)); + GetDouble("HudProcessingHeight", 44)); AutomationProperties.SetLiveSetting( TranscriptText, @@ -710,8 +893,11 @@ _lastStatus is null || break; case DictationStatus.Error: + _entryGeneration++; CancelContentReveal(); StopProcessingAnimation(); + HudSurface.Opacity = 1; + HudSurface.RenderTransform = TransformScale1; _recordingWidth = 0; RecordLamp.Fill = errorBrush; diff --git a/windows/src/Murmur.Core/Abstractions/IStreamingTranscriber.cs b/windows/src/Murmur.Core/Abstractions/IStreamingTranscriber.cs index 3b8b943..647abd1 100644 --- a/windows/src/Murmur.Core/Abstractions/IStreamingTranscriber.cs +++ b/windows/src/Murmur.Core/Abstractions/IStreamingTranscriber.cs @@ -8,8 +8,8 @@ public interface IStreamingTranscriber : ITranscriber /// Fired when a new intermediate partial transcript is available. event Action? PartialTranscriptChanged; - /// Begins a new live streaming recognition session. - void BeginSession(); + /// Begins a session; reports worker/backlog failure without waiting for completion. + void BeginSession(Action? onFailure = null); /// Feeds audio samples into the active streaming session. void AcceptAudio(ReadOnlyMemory audioSamples); diff --git a/windows/src/Murmur.Core/DictationController.cs b/windows/src/Murmur.Core/DictationController.cs index 687d869..5a05db1 100644 --- a/windows/src/Murmur.Core/DictationController.cs +++ b/windows/src/Murmur.Core/DictationController.cs @@ -34,6 +34,9 @@ private sealed class ActiveSessionState(RuntimeState runtime, TextInjectionTarge public long? ReleasedAt { get; set; } public long? InjectedAt { get; set; } public long SampleCount { get; set; } + public Task InFlight { get; set; } = Task.CompletedTask; + public bool Ended { get; set; } + public bool DurationLimited { get; set; } } private readonly IAudioCapture _audioCapture; @@ -49,10 +52,22 @@ private sealed class ActiveSessionState(RuntimeState runtime, TextInjectionTarge private readonly List _audioBuffer = []; private const int RetainedAudioCapacity = 16000 * 30; + private const int MaximumRecordingSamples = 16000 * 60 * 5; private readonly Lock _lock = new(); private Task _sessionTask = Task.CompletedTask; private Task? _stopTask; private Task? _disposeTask; + private Task? _stopCompletion; + private Task? _disposeCompletion; + private volatile bool _requiresRestart; + private static readonly TimeSpan ShutdownBudget = TimeSpan.FromSeconds(3); + internal Func WaitForWorkAsync { get; set; } = + static (work, budget, cancellation) => work.WaitAsync(budget, cancellation); + internal Task DisposalCompletion => _disposeCompletion ?? Task.CompletedTask; + internal Task SessionCompletion => _sessionTask; + + /// True after a deadline leaves native/capture work in quarantine; restart the app before reuse. + public bool RequiresRestart => _requiresRestart; private bool _stopping; private bool _disposed; @@ -145,6 +160,7 @@ public void Start() lock (_lock) { ObjectDisposedException.ThrowIf(_disposed, this); + if (_requiresRestart) throw new InvalidOperationException("RecognitionRestartRequired"); if (_stopTask is { IsCompleted: false }) throw new InvalidOperationException("Controller is still stopping."); _stopping = false; @@ -223,30 +239,60 @@ public Task StopAsync() } var work = _sessionTask; var started = session?.CaptureStarted.Task ?? Task.CompletedTask; - _stopTask = Task.Run(() => StopCoreAsync(work, started)); + _stopCompletion = Task.Run(() => StopCoreAsync(work, started)); + _stopTask = WaitForShutdownAsync(_stopCompletion); + ObserveFailure(_stopTask); return _stopTask; } } private async Task StopCoreAsync(Task sessionWork, Task captureStarted) { - _hotkeySource.Stop(); + try { _hotkeySource.Stop(); } + catch { RequireRestart(null); } await captureStarted.ConfigureAwait(false); await CleanupCaptureAsync().ConfigureAwait(false); await sessionWork.ConfigureAwait(false); lock (_lock) { ClearAudioBuffer(); - SetState(DictationState.Idle); + SetState(_requiresRestart ? DictationState.ErrorState("RecognitionRestartRequired") : DictationState.Idle); } } + private static void ObserveFailure(Task work) => _ = work.ContinueWith( + static completed => { _ = completed.Exception; }, CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + + private void RequireRestart(ActiveSessionState? session) + { + lock (_lock) + { + _requiresRestart = true; + if (session is { Ended: false, InsertionCommitted: false }) session.Cancellation.Cancel(); + SetState(DictationState.ErrorState("RecognitionRestartRequired")); + } + } + + private async Task WaitForShutdownAsync(Task work) + { + try { await WaitForWorkAsync(work, ShutdownBudget, CancellationToken.None).ConfigureAwait(false); } + catch (TimeoutException) { RequireRestart(null); throw; } + } + + private async Task WaitForOperationAsync(ActiveSessionState session, Task work, TimeSpan budget, CancellationToken cancellationToken) + { + session.InFlight = work; + try { await WaitForWorkAsync(work, budget, cancellationToken).ConfigureAwait(false); } + catch (TimeoutException) { RequireRestart(session); throw; } + } + private void OnHotkeyPressed() { ActiveSessionState session; lock (_lock) { - if (_stopping || _disposed || !_sessionTask.IsCompleted || + if (_stopping || _disposed || _requiresRestart || !_sessionTask.IsCompleted || State.Status is not (DictationStatus.Idle or DictationStatus.Error)) return; try @@ -272,7 +318,13 @@ private void OnHotkeyPressed() try { if (!IsCurrentSession(session)) return; - _streamingTranscriber?.BeginSession(); + _streamingTranscriber?.BeginSession(ex => + { + lock (_lock) + { + if (IsCurrent(session)) BeginAbort(session, ex); + } + }); _audioCapture.Start(); lock (_lock) { @@ -307,7 +359,8 @@ private async Task FinishSessionAsync(ActiveSessionState session, CancellationTo await Task.Delay(40, cancellationToken).ConfigureAwait(false); if (!IsCurrentSession(session)) return; // A cancelled wait must not let native resources outlive capture shutdown. - await _audioCapture.StopAsync(CancellationToken.None).ConfigureAwait(false); + await WaitForOperationAsync(session, Task.Run(() => _audioCapture.StopAsync(CancellationToken.None), CancellationToken.None), + ShutdownBudget, cancellationToken).ConfigureAwait(false); float[] audioCopy; long sampleCount; @@ -326,9 +379,13 @@ private async Task FinishSessionAsync(ActiveSessionState session, CancellationTo var audioDurationMs = sampleCount * 1000.0 / 16000.0; var sw = Stopwatch.StartNew(); - var raw = _streamingTranscriber is null - ? await _transcriber.TranscribeAsync(audioCopy, cancellationToken).ConfigureAwait(false) - : await _streamingTranscriber.CompleteSessionAsync(cancellationToken).ConfigureAwait(false); + var recognition = _streamingTranscriber is null + ? Task.Run(() => _transcriber.TranscribeAsync(audioCopy, cancellationToken), CancellationToken.None) + : Task.Run(() => _streamingTranscriber.CompleteSessionAsync(cancellationToken), CancellationToken.None); + var inferenceBudget = _streamingTranscriber is null + ? TimeSpan.FromSeconds(Math.Max(60, sampleCount * 2.0 / 16000)) : TimeSpan.FromSeconds(30); + await WaitForOperationAsync(session, recognition, inferenceBudget, cancellationToken).ConfigureAwait(false); + var raw = await recognition.ConfigureAwait(false); audioCopy = []; if (!IsCurrentSession(session)) return; sw.Stop(); @@ -341,19 +398,28 @@ private async Task FinishSessionAsync(ActiveSessionState session, CancellationTo return; } + lock (_lock) + { + if (!IsCurrent(session)) return; + CurrentTranscript = raw; + TranscriptChanged?.Invoke(raw); + } var repaired = raw; var repairer = _speechRepairer; if (session.Runtime.EnableSelfCorrection && repairer != null) { try { - var repairResult = await repairer.RepairAsync(raw, cancellationToken).ConfigureAwait(false); + var repair = Task.Run(() => repairer.RepairAsync(raw, cancellationToken), CancellationToken.None); + await WaitForOperationAsync(session, repair, inferenceBudget, cancellationToken).ConfigureAwait(false); + var repairResult = await repair.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); if (!string.IsNullOrWhiteSpace(repairResult)) { repaired = repairResult; } } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) { throw; } @@ -417,6 +483,7 @@ private async Task FinishSessionAsync(ActiveSessionState session, CancellationTo } finally { + await WaitForActualWorkAsync(session).ConfigureAwait(false); await CleanupCaptureAsync().ConfigureAwait(false); EndSession(session, error); } @@ -450,7 +517,7 @@ private void RecordMetrics(ActiveSessionState session, double audioMs, double in } private bool IsCurrent(ActiveSessionState session) => - !_stopping && !_disposed && ReferenceEquals(_activeSessionState, session) && + !_stopping && !_disposed && !_requiresRestart && ReferenceEquals(_activeSessionState, session) && !session.Cancellation.IsCancellationRequested; private bool IsCurrentSession(ActiveSessionState session) @@ -460,11 +527,28 @@ private bool IsCurrentSession(ActiveSessionState session) private void BeginAbort(ActiveSessionState session, Exception exception) { + if (session.Ended) return; session.Failure ??= exception; session.Cancellation.Cancel(); - if (session.WorkStarted) return; - session.WorkStarted = true; - _sessionTask = Task.Run(() => AbortSessionAsync(session)); + SetState(DictationState.ErrorState(GetErrorMessage(exception, "Microphone error"))); + if (!session.WorkStarted) + { + session.WorkStarted = true; + _sessionTask = Task.Run(() => AbortSessionAsync(session)); + } + _ = MonitorAbortAsync(_sessionTask); + } + + private async Task MonitorAbortAsync(Task work) + { + try { await WaitForShutdownAsync(work).ConfigureAwait(false); } + catch { /* The public error is already reported; actual cleanup retains resource ownership. */ } + } + + private static async Task WaitForActualWorkAsync(ActiveSessionState session) + { + try { await session.InFlight.ConfigureAwait(false); } + catch { /* Observe late native failures; never release before the actual task has finished. */ } } private async Task AbortSessionAsync(ActiveSessionState session) @@ -477,7 +561,15 @@ private async Task AbortSessionAsync(ActiveSessionState session) private async Task CleanupCaptureAsync() { try { await _audioCapture.StopAsync().ConfigureAwait(false); } - catch { /* Preserve the original capture or transcription failure. */ } + catch + { + if (_audioCapture.IsRunning) + { + RequireRestart(null); + // Capture did not confirm shutdown: retain ownership until process exit. + await Task.Delay(Timeout.InfiniteTimeSpan).ConfigureAwait(false); + } + } try { if (_streamingTranscriber != null) @@ -496,9 +588,12 @@ private void EndSession(ActiveSessionState session, string? error) ClearAudioBuffer(); Level = 0; LevelChanged?.Invoke(0); - error = session.Failure is { } failure ? GetErrorMessage(failure, "Microphone error") : error; + error = _requiresRestart ? "RecognitionRestartRequired" + : session.Failure is { } failure ? GetErrorMessage(failure, "Microphone error") : error; + error ??= session.DurationLimited ? "RecordingDurationLimit" : null; SetState(error == null ? DictationState.Idle : DictationState.ErrorState(error)); } + session.Ended = true; session.Cancellation.Dispose(); } } @@ -507,10 +602,27 @@ private void OnAudioChunkAvailable(ReadOnlyMemory chunk) { lock (_lock) { - if (_activeSessionState is not { } session || !IsCurrent(session) || !State.IsActive) return; - session.SampleCount += chunk.Length; - if (_streamingTranscriber is null) _audioBuffer.AddRange(chunk.Span); - _streamingTranscriber?.AcceptAudio(chunk); + if (_activeSessionState is not { } session || !IsCurrent(session) || !State.IsActive || session.DurationLimited) return; + try + { + var count = (int)Math.Min(chunk.Length, MaximumRecordingSamples - session.SampleCount); + var accepted = chunk[..count]; + if (_streamingTranscriber is null) _audioBuffer.AddRange(accepted.Span); + else _streamingTranscriber.AcceptAudio(accepted); + session.SampleCount += count; + if (session.SampleCount == MaximumRecordingSamples) + { + session.DurationLimited = true; + if (!session.WorkStarted) + { + session.ReleasedAt = Stopwatch.GetTimestamp(); + session.WorkStarted = true; + SetState(new DictationState(DictationStatus.Finishing, "RecordingDurationLimit")); + _sessionTask = Task.Run(() => FinishSessionAsync(session, session.Cancellation.Token)); + } + } + } + catch (Exception ex) { BeginAbort(session, ex); } } } @@ -551,7 +663,9 @@ mode is TextInsertionMode.AtCaret or TextInsertionMode.DocumentEnd : TextInsertionMode.DocumentEnd; private static string GetErrorMessage(Exception exception, string fallbackPrefix) => - exception is TextInjectionException injection + exception.Message is "StreamingBacklogExceeded" or "StreamingRecognitionFailed" + ? exception.Message + : exception is TextInjectionException injection ? injection.UserMessageKey : $"{fallbackPrefix}: {exception.Message}"; @@ -565,8 +679,10 @@ public ValueTask DisposeAsync() { if (_disposeTask != null) return new ValueTask(_disposeTask); _disposed = true; - var stopped = StopAsync(); - _disposeTask = Task.Run(() => DisposeCoreAsync(stopped)); + _ = StopAsync(); + _disposeCompletion = Task.Run(() => DisposeCoreAsync(_stopCompletion!)); + ObserveFailure(_disposeCompletion); + _disposeTask = WaitForShutdownAsync(_disposeCompletion); return new ValueTask(_disposeTask); } } diff --git a/windows/src/Murmur.Platform.Windows/Audio/WasapiAudioCapture.cs b/windows/src/Murmur.Platform.Windows/Audio/WasapiAudioCapture.cs index 273a05d..8da5ab9 100644 --- a/windows/src/Murmur.Platform.Windows/Audio/WasapiAudioCapture.cs +++ b/windows/src/Murmur.Platform.Windows/Audio/WasapiAudioCapture.cs @@ -164,26 +164,23 @@ public async Task StopAsync(CancellationToken cancellationToken = default) TaskCompletionSource? stoppedTask; lock (_lock) { - if (!IsRunning || _capture == null) - { - return; - } - stoppedTask = _stopped; - _capture.StopRecording(); + if (IsRunning && _capture != null) _capture.StopRecording(); } if (stoppedTask != null) { - await stoppedTask.Task.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); + await stoppedTask.Task.WaitAsync(cancellationToken).ConfigureAwait(false); } } private void OnRecordingStopped(object? sender, StoppedEventArgs e) { var failure = e.Exception; + TaskCompletionSource? stopped; lock (_lock) { + stopped = _stopped; IsRunning = false; LastSessionStats = new AudioCaptureStats( _outputSamples == 0 ? 0 : Math.Sqrt(_sumOfSquares / _outputSamples), @@ -193,14 +190,14 @@ private void OnRecordingStopped(object? sender, StoppedEventArgs e) _normalizer = null; LevelChanged?.Invoke(0); - _stopped?.TrySetResult(); } - if (failure != null) + try { - CaptureFailed?.Invoke(failure); + if (failure != null) CaptureFailed?.Invoke(failure); + SessionStopped?.Invoke(); } - SessionStopped?.Invoke(); + finally { stopped?.TrySetResult(); } } private void OnDataAvailable(object? sender, WaveInEventArgs e) diff --git a/windows/src/Murmur.Platform.Windows/Native/Win32.cs b/windows/src/Murmur.Platform.Windows/Native/Win32.cs index aa45c48..efd2ff6 100644 --- a/windows/src/Murmur.Platform.Windows/Native/Win32.cs +++ b/windows/src/Murmur.Platform.Windows/Native/Win32.cs @@ -150,6 +150,17 @@ public struct RECT [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetWindowRect(nint hWnd, out RECT rect); + /// SPI_GETCLIENTAREAANIMATION: false when Windows "Animation effects" is off (reduced motion). + public const uint SPI_GETCLIENTAREAANIMATION = 0x1042; + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SystemParametersInfoW( + uint uiAction, + uint uiParam, + [MarshalAs(UnmanagedType.Bool)] out bool pvParam, + uint fWinIni); + [DllImport("user32.dll", SetLastError = true)] public static extern nint SetWindowLongPtrW(nint hWnd, int nIndex, nint dwNewLong); diff --git a/windows/src/Murmur.Speech/LlamaSharpSpeechRepairer.cs b/windows/src/Murmur.Speech/LlamaSharpSpeechRepairer.cs index 262f60e..c227a83 100644 --- a/windows/src/Murmur.Speech/LlamaSharpSpeechRepairer.cs +++ b/windows/src/Murmur.Speech/LlamaSharpSpeechRepairer.cs @@ -25,6 +25,11 @@ public sealed class LlamaSharpSpeechRepairer : ISpeechRepairer, IDisposable private readonly string? _modelPath; private readonly ISpeechRepairer _fallback; private readonly object _lock = new(); + private readonly SemaphoreSlim _inferenceGate = new(1, 1); + private readonly Func? _tokenize; + private readonly Func>? _infer; + private const int ContextTokens = 2048; + private const int ContextMargin = 16; private LLamaWeights? _weights; private StatelessExecutor? _executor; @@ -42,60 +47,73 @@ public LlamaSharpSpeechRepairer(string? modelPath = null, ISpeechRepairer? fallb _fallback = fallback ?? new RuleBasedSpeechRepairer(); } - /// - public async Task RepairAsync(string rawTranscript, CancellationToken cancellationToken = default) + internal LlamaSharpSpeechRepairer(Func tokenize, + Func> infer) + : this() { - if (string.IsNullOrEmpty(rawTranscript)) - { - return rawTranscript; - } - - if (string.IsNullOrWhiteSpace(rawTranscript)) - { - return rawTranscript; - } + _tokenize = tokenize; + _infer = infer; + } - if (_isDisposed || !EnsureLoaded() || _executor == null) - { - return await _fallback.RepairAsync(rawTranscript, cancellationToken).ConfigureAwait(false); - } + private static string CheckedResult(string result, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return result; + } + /// + public async Task RepairAsync(string rawTranscript, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(rawTranscript)) return CheckedResult(rawTranscript, cancellationToken); + await _inferenceGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { - var prompt = FormatChatPrompt(DefaultSystemPrompt, rawTranscript); - - var inferenceParams = new InferenceParams - { - MaxTokens = 256, - AntiPrompts = new[] { "<|im_end|>", "<|im_start|>", "\n<|im_start|>" }, - SamplingPipeline = new DefaultSamplingPipeline - { - Temperature = 0.0f - } - }; - - var sb = new StringBuilder(); - await foreach (var token in _executor.InferAsync(prompt, inferenceParams, cancellationToken).ConfigureAwait(false)) + if (_isDisposed || (_infer == null && (!EnsureLoaded() || _executor == null))) { - sb.Append(token); + cancellationToken.ThrowIfCancellationRequested(); + var fallback = await _fallback.RepairAsync(rawTranscript, cancellationToken).ConfigureAwait(false); + return CheckedResult(fallback, cancellationToken); } - var result = CleanOutput(sb.ToString()); - if (string.IsNullOrWhiteSpace(result)) + try { - return await _fallback.RepairAsync(rawTranscript, cancellationToken).ConfigureAwait(false); + var prompt = FormatChatPrompt(DefaultSystemPrompt, rawTranscript); + // Match StatelessExecutor 0.22 exactly: our ChatML is already formatted, BOS/special enabled. + int Count(string text) => _tokenize != null ? _tokenize(text) + : _weights!.Tokenize(text, true, true, Encoding.UTF8).Length; + var promptTokens = Count(prompt); + var rawTokens = Count(rawTranscript); + var reserve = Math.Max(256L, 2L * rawTokens + 64); + var context = _executor?.Context.ContextSize ?? ContextTokens; + if (promptTokens < 0 || rawTokens < 0 || promptTokens + reserve + ContextMargin > context) + return CheckedResult(rawTranscript, cancellationToken); + var maximum = (int)reserve; + cancellationToken.ThrowIfCancellationRequested(); + var chunks = _infer != null ? _infer(prompt, maximum, cancellationToken) + : _executor!.InferAsync(prompt, new InferenceParams + { + MaxTokens = maximum, + AntiPrompts = ["<|im_end|>", "<|im_start|>", "\n<|im_start|>"], + SamplingPipeline = new DefaultSamplingPipeline { Temperature = 0.0f } + }, cancellationToken); + var output = new StringBuilder(); + var yielded = 0; + await foreach (var chunk in chunks.ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + // Only the pinned StatelessExecutor yields once per non-EOS token, including empty UTF8 chunks. + if (++yielded >= maximum) return CheckedResult(rawTranscript, cancellationToken); + output.Append(chunk); + } + cancellationToken.ThrowIfCancellationRequested(); + var result = CleanOutput(output.ToString()); + return CheckedResult(string.IsNullOrWhiteSpace(result) ? rawTranscript : result, cancellationToken); } - - return result; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception) - { - return await _fallback.RepairAsync(rawTranscript, cancellationToken).ConfigureAwait(false); + catch (OperationCanceledException) { throw; } + catch (Exception) { return CheckedResult(rawTranscript, cancellationToken); } } + finally { _inferenceGate.Release(); } } private bool EnsureLoaded() @@ -123,12 +141,12 @@ private bool EnsureLoaded() { var parameters = new ModelParams(_modelPath) { - ContextSize = 1024, + ContextSize = ContextTokens, GpuLayerCount = 0 }; _weights = LLamaWeights.LoadFromFile(parameters); - _executor = new StatelessExecutor(_weights, parameters); + _executor = new StatelessExecutor(_weights, parameters) { ApplyTemplate = false }; return true; } catch (Exception) @@ -182,17 +200,18 @@ internal static string CleanOutput(string output) /// public void Dispose() { - if (_isDisposed) - { - return; - } - - lock (_lock) + _inferenceGate.Wait(); + try { - _isDisposed = true; - _executor = null; - _weights?.Dispose(); - _weights = null; + lock (_lock) + { + if (_isDisposed) return; + _isDisposed = true; + _executor = null; + _weights?.Dispose(); + _weights = null; + } } + finally { _inferenceGate.Release(); } } } diff --git a/windows/src/Murmur.Speech/NemotronStreamingTranscriber.cs b/windows/src/Murmur.Speech/NemotronStreamingTranscriber.cs index 8fb03cf..75e01dd 100644 --- a/windows/src/Murmur.Speech/NemotronStreamingTranscriber.cs +++ b/windows/src/Murmur.Speech/NemotronStreamingTranscriber.cs @@ -9,20 +9,34 @@ namespace Murmur.Speech; /// public sealed class NemotronStreamingTranscriber : IStreamingTranscriber { - private readonly OnlineRecognizer _recognizer; + internal sealed record NativeSession(Action Accept, Func IsReady, Action Decode, + Func GetText, Action InputFinished, Action Dispose); + private readonly Func _createStream; + private readonly Action _disposeRecognizer; private readonly Lock _lock = new(); - private sealed class StreamingSession(OnlineStream stream, Channel channel) + private const int MaximumPendingSamples = 144000; + private const int MaximumPendingItems = 1024; + private sealed class StreamingSession(NativeSession stream, Channel channel, Action? onFailure) { - public OnlineStream Stream { get; } = stream; + public NativeSession Stream { get; } = stream; public Channel Channel { get; } = channel; public Task Worker { get; set; } = Task.CompletedTask; public int Cancelled; + public Action? OnFailure { get; } = onFailure; + public Exception? Failure; + public int PendingSamples; + public int PendingItems; + public TaskCompletionSource? Drained; } private StreamingSession? _session; private Task? _closeTask; private Task? _disposeTask; private bool _disposed; + internal (int Samples, int Items) PendingAudio + { + get { lock (_lock) return _session is { } s ? (s.PendingSamples, s.PendingItems) : (0, 0); } + } /// public event Action? PartialTranscriptChanged; @@ -53,11 +67,27 @@ public NemotronStreamingTranscriber(string? modelDir = null) config.ModelConfig.Provider = "cpu"; config.DecodingMethod = "greedy_search"; - _recognizer = new OnlineRecognizer(config); + var recognizer = new OnlineRecognizer(config); + _createStream = () => + { + var stream = recognizer.CreateStream(); + try { stream.SetOption("language", "auto"); } + catch { stream.Dispose(); throw; } + return new NativeSession(samples => stream.AcceptWaveform(16000, samples), + () => recognizer.IsReady(stream), () => recognizer.Decode(stream), + () => recognizer.GetResult(stream).Text ?? string.Empty, stream.InputFinished, stream.Dispose); + }; + _disposeRecognizer = recognizer.Dispose; + } + + internal NemotronStreamingTranscriber(Func createStream, Action disposeRecognizer) + { + _createStream = createStream; + _disposeRecognizer = disposeRecognizer; } /// - public void BeginSession() + public void BeginSession(Action? onFailure = null) { lock (_lock) { @@ -69,16 +99,16 @@ public void BeginSession() _closeTask = null; - var sessionStream = _recognizer.CreateStream(); + var sessionStream = _createStream(); try { - sessionStream.SetOption("language", "auto"); - var sessionChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + var sessionChannel = Channel.CreateBounded(new BoundedChannelOptions(MaximumPendingItems) { SingleReader = true, - SingleWriter = false + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait }); - var session = new StreamingSession(sessionStream, sessionChannel); + var session = new StreamingSession(sessionStream, sessionChannel, onFailure); _session = session; session.Worker = Task.Run(() => ProcessAudioLoopAsync(session)); } @@ -99,10 +129,40 @@ public void AcceptAudio(ReadOnlyMemory audioSamples) { if (_disposed || _closeTask != null || _session is not { } session || Volatile.Read(ref session.Cancelled) != 0) return; - session.Channel.Writer.TryWrite(audioSamples.ToArray()); + // Include the item being decoded; check before allocating or adding to the budget. + if (audioSamples.Length > MaximumPendingSamples - session.PendingSamples || + session.PendingItems >= MaximumPendingItems) + { + FailSessionLocked(session, new InvalidOperationException("StreamingBacklogExceeded")); + return; + } + var copy = audioSamples.ToArray(); + session.PendingSamples += copy.Length; + session.PendingItems++; + if (!session.Channel.Writer.TryWrite(copy)) + { + session.PendingSamples -= copy.Length; + session.PendingItems--; + FailSessionLocked(session, new InvalidOperationException("StreamingBacklogExceeded")); + } } } + private static void FailSessionLocked(StreamingSession session, Exception failure) + { + if (session.Failure != null) return; + session.Failure = failure; + Volatile.Write(ref session.Cancelled, 1); + session.Channel.Writer.TryComplete(); + session.Drained?.TrySetResult(); + // Controller capture and native locks have opposite entry paths: never notify under this lock. + ThreadPool.QueueUserWorkItem(_ => + { + try { session.OnFailure?.Invoke(failure); } + catch { /* A notification must not escape into the capture/worker thread. */ } + }); + } + private async Task ProcessAudioLoopAsync(StreamingSession session) { var stream = session.Stream; @@ -111,18 +171,34 @@ private async Task ProcessAudioLoopAsync(StreamingSession session) { await foreach (var samples in session.Channel.Reader.ReadAllAsync().ConfigureAwait(false)) { - if (Volatile.Read(ref session.Cancelled) != 0) return; - stream.AcceptWaveform(16000, samples); - while (Volatile.Read(ref session.Cancelled) == 0 && _recognizer.IsReady(stream)) + try { - _recognizer.Decode(stream); if (Volatile.Read(ref session.Cancelled) != 0) return; - var text = _recognizer.GetResult(stream).Text ?? string.Empty; - if (text.Length > 0 && !string.Equals(text, lastPartial, StringComparison.Ordinal) && - Volatile.Read(ref session.Cancelled) == 0) + stream.Accept(samples); + while (Volatile.Read(ref session.Cancelled) == 0 && stream.IsReady()) + { + stream.Decode(); + if (Volatile.Read(ref session.Cancelled) != 0) return; + var text = stream.GetText(); + if (text.Length > 0 && !string.Equals(text, lastPartial, StringComparison.Ordinal) && + Volatile.Read(ref session.Cancelled) == 0) + { + lastPartial = text; + PartialTranscriptChanged?.Invoke(text); + } + } + } + finally + { + lock (_lock) { - lastPartial = text; - PartialTranscriptChanged?.Invoke(text); + session.PendingSamples -= samples.Length; + session.PendingItems--; + if (session.PendingItems == 0) + { + session.Drained?.TrySetResult(); + session.Drained = null; + } } } } @@ -131,6 +207,10 @@ private async Task ProcessAudioLoopAsync(StreamingSession session) { // Normal channel completion } + catch (Exception ex) + { + lock (_lock) FailSessionLocked(session, new InvalidOperationException("StreamingRecognitionFailed", ex)); + } } /// @@ -164,7 +244,10 @@ private Task StartClosingSession(bool decodeFinal) lock (_lock) { if (!decodeFinal && _session is { } cancelling) + { Volatile.Write(ref cancelling.Cancelled, 1); + cancelling.Drained?.TrySetResult(); + } if (_closeTask != null) { return _closeTask; @@ -187,16 +270,17 @@ private async Task FinishClosingSessionAsync(StreamingSession session, b try { await session.Worker.ConfigureAwait(false); + if (session.Failure is { } failure) throw failure; if (decodeFinal && Volatile.Read(ref session.Cancelled) == 0) { stream.InputFinished(); - while (Volatile.Read(ref session.Cancelled) == 0 && _recognizer.IsReady(stream)) + while (Volatile.Read(ref session.Cancelled) == 0 && stream.IsReady()) { - _recognizer.Decode(stream); + stream.Decode(); } if (Volatile.Read(ref session.Cancelled) == 0) - return _recognizer.GetResult(stream).Text ?? string.Empty; + return stream.GetText(); } return string.Empty; } @@ -217,15 +301,49 @@ private async Task FinishClosingSessionAsync(StreamingSession session, b public async Task TranscribeAsync(ReadOnlyMemory audioSamples, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - BeginSession(); + StreamingSession session; + lock (_lock) + { + BeginSession(); + session = _session!; + } try { - AcceptAudio(audioSamples); - return await CompleteSessionAsync(cancellationToken).ConfigureAwait(false); + for (var offset = 0; offset < audioSamples.Length;) + { + cancellationToken.ThrowIfCancellationRequested(); + var count = Math.Min(MaximumPendingSamples, audioSamples.Length - offset); + Task? drained; + lock (_lock) + { + if (session.Failure is { } failure) throw failure; + if (_disposed || !ReferenceEquals(_session, session) || _closeTask != null || session.Cancelled != 0) + throw new OperationCanceledException("Streaming session ended before batch completion."); + AcceptAudio(audioSamples.Slice(offset, count)); + if (session.Failure is { } acceptFailure) throw acceptFailure; + drained = session.PendingItems == 0 ? null : + (session.Drained ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)).Task; + } + offset += count; + if (drained != null) await drained.WaitAsync(cancellationToken).ConfigureAwait(false); + } + cancellationToken.ThrowIfCancellationRequested(); + Task completion; + lock (_lock) + { + if (session.Failure is { } failure) throw failure; + if (_disposed || !ReferenceEquals(_session, session) || _closeTask != null || session.Cancelled != 0) + throw new OperationCanceledException("Streaming session ended before batch completion."); + completion = StartClosingSession(decodeFinal: true); + } + return await completion.WaitAsync(cancellationToken).ConfigureAwait(false); } finally { - await CancelSessionAsync().ConfigureAwait(false); + Task cleanup; + lock (_lock) cleanup = ReferenceEquals(_session, session) ? StartClosingSession(decodeFinal: false) : Task.CompletedTask; + try { await cleanup.ConfigureAwait(false); } + catch when (cancellationToken.IsCancellationRequested) { cancellationToken.ThrowIfCancellationRequested(); } } } @@ -239,7 +357,8 @@ public void Dispose() _disposeTask ??= Task.Run(async () => { try { await CancelSessionAsync().ConfigureAwait(false); } - finally { _recognizer.Dispose(); } + catch { /* Cleanup observes the session failure already delivered to the caller. */ } + finally { _disposeRecognizer(); } }); disposing = _disposeTask; } diff --git a/windows/tests/Murmur.App.Tests/ControllerRetirementTests.cs b/windows/tests/Murmur.App.Tests/ControllerRetirementTests.cs new file mode 100644 index 0000000..ed183b6 --- /dev/null +++ b/windows/tests/Murmur.App.Tests/ControllerRetirementTests.cs @@ -0,0 +1,102 @@ +using System.Reflection; +using Avalonia; +using Avalonia.Headless.XUnit; +using Murmur.Core; +using Murmur.Core.Abstractions; +using Shouldly; + +namespace Murmur.App.Tests; + +public class ControllerRetirementTests +{ + private sealed class Audio : IAudioCapture + { + public event Action>? AudioChunkAvailable; + public event Action? LevelChanged { add { } remove { } } + public event Action? CaptureFailed { add { } remove { } } + public bool IsRunning { get; private set; } + public AudioCaptureFormat? Format => null; + public AudioCaptureStats? LastSessionStats => null; + public int Disposals { get; private set; } + public void Start() { IsRunning = true; AudioChunkAvailable?.Invoke(new float[160]); } + public Task StopAsync(CancellationToken cancellationToken = default) { IsRunning = false; return Task.CompletedTask; } + public void Dispose() => Disposals++; + } + private sealed class Keys : IHotkeySource + { + public event Action? Pressed; + public event Action? Released; + public void Press() => Pressed?.Invoke(); + public void Release() => Released?.Invoke(); + public void Start() { } + public void Stop() { } + public void Dispose() { } + } + private sealed class Native : ITranscriber + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Returned { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int Disposals { get; private set; } + public Task TranscribeAsync(ReadOnlyMemory samples, CancellationToken cancellationToken = default) + { Entered.SetResult(); return Returned.Task; } + public void Dispose() { Returned.Task.IsCompleted.ShouldBeTrue(); Disposals++; } + } + private sealed class Injector : ITextInjector + { + public TextInjectionTarget CaptureTarget() => new(1, 2); + public void Insert(string text, TextInjectionTarget target, Murmur.Core.Settings.TextInsertionMode mode, + bool restorePreviousWindow = false) => throw new InvalidOperationException("Late result must never be inserted"); + } + + [AvaloniaFact] + public async Task ModelSwitchCannotCreateReplacementAfterBoundedControllerDisposal() + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + var app = Application.Current.ShouldBeOfType(); + var ownerField = typeof(App).GetField("_controller", flags)!; + var recoveryField = typeof(App).GetField("_recoveryTranscript", flags)!; + var previousRecovery = recoveryField.GetValue(app); + ownerField.GetValue(app).ShouldBeNull(); + var audio = new Audio(); + var keys = new Keys(); + var native = new Native(); + var controller = new DictationController(audio, keys, native, new Injector(), "fake"); + var expire = false; + Func wait = (work, budget, cancellation) => + expire && budget == TimeSpan.FromSeconds(3) ? Task.FromException(new TimeoutException()) : work.WaitAsync(cancellation); + typeof(DictationController).GetProperty("WaitForWorkAsync", flags)!.SetValue(controller, wait); + ownerField.SetValue(app, controller); + try + { + controller.Start(); keys.Press(); keys.Release(); + await native.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + typeof(DictationController).GetProperty(nameof(DictationController.CurrentTranscript))! + .SetValue(controller, "incomplete recovery"); + expire = true; + var retirement = (Task)typeof(App).GetMethod("DisposeControllerAsync", flags)!.Invoke(app, null)!; + await Should.ThrowAsync(() => retirement); + typeof(App).GetField("_recognitionBlocked", flags)!.GetValue(app).ShouldBe(true); + recoveryField.GetValue(app).ShouldBe("incomplete recovery"); + native.Disposals.ShouldBe(0); + audio.Disposals.ShouldBe(0); + await (Task)typeof(App).GetMethod("StartControllerAsync", flags)!.Invoke(app, null)!; + ownerField.GetValue(app).ShouldBeNull(); + native.Returned.SetResult("late"); + await ((Task)typeof(DictationController).GetProperty("DisposalCompletion", flags)! + .GetValue(controller)!).WaitAsync(TimeSpan.FromSeconds(5)); + native.Disposals.ShouldBe(1); + audio.Disposals.ShouldBe(1); + } + finally + { + native.Returned.TrySetResult("late"); + expire = false; + try { await controller.DisposeAsync(); } catch (TimeoutException) { } + await ((Task)typeof(DictationController).GetProperty("DisposalCompletion", flags)! + .GetValue(controller)!).WaitAsync(TimeSpan.FromSeconds(5)); + ownerField.SetValue(app, null); + recoveryField.SetValue(app, previousRecovery); + typeof(App).GetField("_recognitionBlocked", flags)!.SetValue(app, false); + } + } +} diff --git a/windows/tests/Murmur.App.Tests/HudWindowTests.cs b/windows/tests/Murmur.App.Tests/HudWindowTests.cs index a06ef96..6a8e505 100644 --- a/windows/tests/Murmur.App.Tests/HudWindowTests.cs +++ b/windows/tests/Murmur.App.Tests/HudWindowTests.cs @@ -435,7 +435,8 @@ public async Task HudWindow_IdleFollowedByImmediateListening_RemainsVisible() [AvaloniaFact] public async Task HudWindow_EntryKeepsContentHiddenUntilRevealDelay() { - var hud = new HudWindow(); + var hud = new HudWindow { IsReducedMotion = () => false }; + hud.Resources["HudContentRevealDelayMs"] = 60000d; hud.UpdateState( new DictationState(DictationStatus.Starting), @@ -454,6 +455,13 @@ public async Task HudWindow_EntryKeepsContentHiddenUntilRevealDelay() surface.Height.ShouldBe(52); content.Opacity.ShouldBe(0); + var timer = typeof(HudWindow).GetField("_contentRevealTimer", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(hud).ShouldBeOfType(); + timer.IsEnabled.ShouldBeTrue(); + timer.Interval.ShouldBe(TimeSpan.FromMinutes(1)); + timer.Interval = TimeSpan.FromMilliseconds(1); + await Task.Delay(120); Dispatcher.UIThread.RunJobs(); @@ -491,7 +499,7 @@ public async Task HudWindow_IdleCancelsPendingContentReveal() [AvaloniaFact] public async Task HudWindow_AudioLevelUpdatesDoNotRestartContentReveal() { - var hud = new HudWindow(); + var hud = new HudWindow { IsReducedMotion = () => false }; hud.UpdateState( new DictationState(DictationStatus.Starting), @@ -672,6 +680,73 @@ public void HudWindow_FinishingUsesBoundedTranscriptPreview() transcript.Text.ShouldStartWith("… "); } + [AvaloniaFact] + public void HudWindow_RainbowRim_OnlyDuringFinishing() + { + var hud = new HudWindow { IsReducedMotion = () => false }; + var rim = hud.FindControl("RainbowRim")!; + + hud.UpdateState(new DictationState(DictationStatus.Starting), 0, ""); + Dispatcher.UIThread.RunJobs(); + rim.Classes.ShouldNotContain("active"); + + hud.UpdateState(new DictationState(DictationStatus.Listening), 0.5f, "words"); + Dispatcher.UIThread.RunJobs(); + rim.Classes.ShouldNotContain("active"); + hud.IsRainbowAnimating.ShouldBeFalse(); + + hud.UpdateState(new DictationState(DictationStatus.Finishing), 0, "words"); + Dispatcher.UIThread.RunJobs(); + rim.Classes.ShouldContain("active"); + hud.IsRainbowAnimating.ShouldBeTrue(); + + hud.UpdateState(new DictationState(DictationStatus.Error, "boom"), 0, ""); + Dispatcher.UIThread.RunJobs(); + rim.Classes.ShouldNotContain("active"); + hud.IsRainbowAnimating.ShouldBeFalse(); + } + + [AvaloniaFact] + public void HudWindow_RainbowRim_StopsOnIdleAndClose() + { + var hud = new HudWindow { IsReducedMotion = () => false }; + var rim = hud.FindControl("RainbowRim")!; + + hud.UpdateState(new DictationState(DictationStatus.Finishing), 0, ""); + Dispatcher.UIThread.RunJobs(); + hud.IsRainbowAnimating.ShouldBeTrue(); + + hud.UpdateState(DictationState.Idle, 0, ""); + Dispatcher.UIThread.RunJobs(); + rim.Classes.ShouldNotContain("active"); + hud.IsRainbowAnimating.ShouldBeFalse(); + + hud.UpdateState(new DictationState(DictationStatus.Finishing), 0, ""); + Dispatcher.UIThread.RunJobs(); + hud.IsRainbowAnimating.ShouldBeTrue(); + + hud.Close(); + Dispatcher.UIThread.RunJobs(); + rim.Classes.ShouldNotContain("active"); + hud.IsRainbowAnimating.ShouldBeFalse(); + } + + [AvaloniaFact] + public void HudWindow_RainbowRim_ReducedMotionIsStatic() + { + var hud = new HudWindow { IsReducedMotion = () => true }; + var rim = hud.FindControl("RainbowRim")!; + var brush = (ConicGradientBrush)rim.Resources["HudRainbowBrush"]!; + brush.Angle = 42; + + hud.UpdateState(new DictationState(DictationStatus.Finishing), 0, ""); + Dispatcher.UIThread.RunJobs(); + + rim.Classes.ShouldContain("active"); + hud.IsRainbowAnimating.ShouldBeFalse(); + brush.Angle.ShouldBe(-90); + } + [AvaloniaFact] public void HudWindow_LongSingleWordDoesNotOverflowSurface() { diff --git a/windows/tests/Murmur.App.Tests/RobustnessNoticeTests.cs b/windows/tests/Murmur.App.Tests/RobustnessNoticeTests.cs new file mode 100644 index 0000000..f64a6d4 --- /dev/null +++ b/windows/tests/Murmur.App.Tests/RobustnessNoticeTests.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Murmur.App.Localization; +using Murmur.App.Views; +using Murmur.Core.State; +using Shouldly; +using Xunit; + +namespace Murmur.App.Tests; + +public class RobustnessNoticeTests +{ + [Theory] + [InlineData("RecordingDurationLimit")] + [InlineData("StreamingBacklogExceeded")] + [InlineData("StreamingRecognitionFailed")] + [InlineData("RecognitionRestartRequired")] + public void SafetyNoticesResolveInBothLanguages(string key) + { + var method = typeof(App).GetMethod("LocalizeDictationState", BindingFlags.Static | BindingFlags.NonPublic)!; + try + { + UiText.SetLanguage(UiLanguage.German); + var german = (DictationState)method.Invoke(null, [DictationState.ErrorState(key)])!; + german.Message.ShouldNotBe(key); + UiText.SetLanguage(UiLanguage.English); + var english = (DictationState)method.Invoke(null, [DictationState.ErrorState(key)])!; + english.Message.ShouldNotBe(key); + english.Message.ShouldNotBe(german.Message); + } + finally { UiText.SetLanguage(UiLanguage.English); } + } + + [AvaloniaFact] + public void DurationNoticeIsVisibleWhileFinishingThenInError() + { + var hud = new HudWindow(); + var message = UiText.Text("RecordingDurationLimit"); + try + { + hud.UpdateState(new DictationState(DictationStatus.Finishing, message), 0, "partial"); + Dispatcher.UIThread.RunJobs(); + hud.FindControl("TranscriptText")!.Text.ShouldBe(message); + hud.UpdateState(DictationState.ErrorState(message), 0, "final"); + Dispatcher.UIThread.RunJobs(); + hud.FindControl("TranscriptText")!.Text.ShouldNotBeNull().ShouldContain(message); + } + finally { hud.Close(); } + } +} diff --git a/windows/tests/Murmur.App.Tests/SettingsWindowTests.cs b/windows/tests/Murmur.App.Tests/SettingsWindowTests.cs index fa86026..0485183 100644 --- a/windows/tests/Murmur.App.Tests/SettingsWindowTests.cs +++ b/windows/tests/Murmur.App.Tests/SettingsWindowTests.cs @@ -641,12 +641,13 @@ public void Axaml_ContainsNoGradientsPurpleOrIllegalRed() if (relativeName.Equals("Styles\\MurmurTokens.axaml", StringComparison.OrdinalIgnoreCase)) { - foreach (var key in new[] { "LensPrimaryBrush", "LensProgressBrush", "LensSpecularBrush" }) + foreach (var key in new[] { "LensPrimaryBrush", "LensProgressBrush", "LensSpecularBrush", "HudSpecularHairlineBrush" }) { content.ShouldContain($"x:Key=\"{key}\""); } - content.Split(" normal++, () => exited++, (pending, budget) => + { + pending.ShouldBe(work.Task); + budget.ShouldBe(TimeSpan.FromSeconds(3)); + return Task.FromException(new TimeoutException()); + }); + exited.ShouldBe(1); + normal.ShouldBe(0); + work.Task.IsCompleted.ShouldBeFalse(); + work.SetException(new IOException("late cleanup failure")); + } + + [Fact] + public async Task HealthyExitUsesNormalCallback() + { + var normal = 0; + await ShutdownDeadline.RunAsync(Task.CompletedTask, () => normal++, () => throw new InvalidOperationException()); + normal.ShouldBe(1); + } +} diff --git a/windows/tests/Murmur.App.Tests/StudioHudTests.cs b/windows/tests/Murmur.App.Tests/StudioHudTests.cs new file mode 100644 index 0000000..5f72848 --- /dev/null +++ b/windows/tests/Murmur.App.Tests/StudioHudTests.cs @@ -0,0 +1,484 @@ +using System.Reflection; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using Murmur.App.Controls; +using Murmur.App.Views; +using Murmur.Core.State; +using Shouldly; + +namespace Murmur.App.Tests; + +public class StudioHudTests +{ + private static readonly string[] DotNames = ["ProcessingDot1", "ProcessingDot2", "ProcessingDot3"]; + private static T Part(HudWindow hud, string name) where T : Control => hud.FindControl(name)!; + + private static object? Field(object instance, string name) => instance.GetType() + .GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(instance); + + [AvaloniaFact] + public void RecordingPulse_BrightDimBrightWithoutRestartingOnTranscriptUpdates() + { + var hud = new HudWindow { RequestedThemeVariant = ThemeVariant.Dark, IsReducedMotion = () => false }; + try + { + hud.UpdateState(DictationState.Listening, .5f, "first"); + var stroke = Part(hud, "RecordingStroke"); + stroke.ShouldNotBeNull(); + hud.AdvanceRimVisuals(TimeSpan.Zero); + stroke.Opacity.ShouldBe(1); + hud.AdvanceRimVisuals(TimeSpan.FromSeconds(.6)); + stroke.Opacity.ShouldBe(.55, .0001); + hud.UpdateState(DictationState.Listening, .5f, "next words"); + hud.AdvanceRimVisuals(TimeSpan.FromSeconds(.6)); + stroke.Opacity.ShouldBe(.55, .0001); + hud.AdvanceRimVisuals(TimeSpan.FromSeconds(1.2)); + stroke.Opacity.ShouldBe(1, .0001); + Field(hud, "_recordingAnimating").ShouldBe(true); + hud.IsRainbowAnimating.ShouldBeFalse(); + var red = stroke.BorderBrush.ShouldBeAssignableTo()!; + red.Color.R.ShouldBeGreaterThan((byte)230); + } + finally { hud.Close(); } + } + + [AvaloniaFact] + public void RecordingPulse_StopsOnLifecycleChangesAndReducedMotion() + { + foreach (var ending in new[] { "starting", "finishing", "idle", "error", "hidden", "close", "reduced" }) + { + var reduced = false; + var hud = new HudWindow { IsReducedMotion = () => reduced }; + try + { + hud.UpdateState(DictationState.Listening, .5f, "words"); + hud.AdvanceRimVisuals(TimeSpan.Zero); + hud.AdvanceRimVisuals(TimeSpan.FromSeconds(.6)); + switch (ending) + { + case "starting": hud.UpdateState(DictationState.Starting, 0, ""); break; + case "finishing": hud.UpdateState(DictationState.Finishing, 0, "words"); break; + case "idle": hud.UpdateState(DictationState.Idle, 0, ""); break; + case "error": hud.UpdateState(DictationState.ErrorState("lost"), 0, ""); break; + case "hidden": hud.Hide(); break; + case "close": hud.Close(); break; + case "reduced": reduced = true; hud.UpdateState(DictationState.Listening, .5f, "words"); break; + } + Field(hud, "_recordingAnimating").ShouldBe(false); + var stroke = Part(hud, "RecordingStroke"); + var opacity = stroke.Opacity; + hud.AdvanceRimVisuals(TimeSpan.FromSeconds(1.2)); + stroke.Opacity.ShouldBe(opacity); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(3); + Dispatcher.UIThread.RunJobs(); + if (ending != "finishing") Field(hud, "_rimFrameRequested").ShouldBe(false); + if (reduced) + { + stroke.Opacity.ShouldBe(1); + Part(hud, "HudMeter").IsAnimationRunning.ShouldBeFalse(); + } + } + finally { hud.Close(); } + } + } + + [AvaloniaFact] + public async Task LateLevelUpdates_CannotUndoLatestLifecycleState() + { + var hud = new HudWindow { IsReducedMotion = () => false }; + try + { + hud.UpdateState(DictationState.Listening, .6f, "words"); + hud.UpdateState(DictationState.ErrorState("lost"), 0, "words"); + hud.UpdateLevel(DictationStatus.Listening, .9f); + var meter = Part(hud, "HudMeter"); + meter.IsActive.ShouldBeFalse(); + meter.IsAnimationRunning.ShouldBeFalse(); + await Task.Run(() => hud.UpdateLevel(DictationStatus.Listening, .9f)); + Dispatcher.UIThread.RunJobs(); + meter.IsActive.ShouldBeFalse(); + meter.IsAnimationRunning.ShouldBeFalse(); + hud.UpdateState(DictationState.Listening, .8f, "again"); + hud.UpdateLevel(DictationStatus.Finishing, 0); + meter.IsActive.ShouldBeTrue(); + meter.Level.ShouldBe(.8f); + hud.Hide(); + hud.UpdateLevel(DictationStatus.Listening, .9f); + meter.IsAnimationRunning.ShouldBeFalse(); + meter.CurrentPosition.ShouldBe(0); + } + finally { hud.Close(); } + } + + [AvaloniaFact] + public void RapidEntryAndRestart_RetainLatestTextPlacementAndCancelOldHide() + { + var hud = new HudWindow { IsReducedMotion = () => false }; + try + { + hud.SetPlacement("bottom-right"); + hud.UpdateState(DictationState.Starting, 0, ""); + hud.UpdateState(DictationState.Listening, .6f, "first"); + hud.UpdateState(DictationState.Finishing, 0, "first"); + hud.UpdateState(DictationState.Idle, 0, ""); + hud.UpdateState(DictationState.Listening, .8f, "latest"); + Dispatcher.UIThread.RunJobs(); + hud.IsVisible.ShouldBeTrue(); + Part(hud, "TranscriptText").Text.ShouldBe("latest"); + Part(hud, "RecordingRim").Classes.ShouldContain("active"); + Part(hud, "RainbowRim").Classes.ShouldNotContain("active"); + Part(hud, "HudSurface").HorizontalAlignment.ShouldBe(Avalonia.Layout.HorizontalAlignment.Right); + Part(hud, "HudContentSurface").VerticalAlignment.ShouldBe(Avalonia.Layout.VerticalAlignment.Bottom); + Field(hud, "_hideTimer").ShouldBeNull(); + Field(hud, "_processingTimer").ShouldBeNull(); + hud.IsRainbowAnimating.ShouldBeFalse(); + } + finally { hud.Close(); } + } + + // Avalonia's clock is internal; drive its real transition engine without wall-clock sleeps. + private sealed class ManualClock + { + private readonly object _clock = Activator.CreateInstance( + typeof(Animatable).Assembly.GetType("Avalonia.Animation.Clock")!, nonPublic: true)!; + public void Attach(Animatable target) => typeof(Animatable) + .GetProperty("Clock", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(target, _clock); + public void Advance(double seconds) => _clock.GetType().BaseType! + .GetMethod("Pulse", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)! + .Invoke(_clock, [TimeSpan.FromSeconds(seconds)]); + } + + [AvaloniaFact] + public void RimFade_ReversesFromCurrentOpacityAndErrorAbortsImmediately() + { + var hud = new HudWindow { IsReducedMotion = () => false }; + try + { + hud.UpdateState(DictationState.Starting, 0, ""); + Dispatcher.UIThread.RunJobs(); + var recording = Part(hud, "RecordingRim"); + var rainbow = Part(hud, "RainbowRim"); + var clock = new ManualClock(); + clock.Attach(recording); + clock.Attach(rainbow); + hud.UpdateState(DictationState.Listening, .5f, "words"); + clock.Advance(0); + clock.Advance(.2); + recording.Opacity.ShouldBe(1, .0001); + hud.UpdateState(DictationState.Finishing, 0, "words"); + clock.Advance(.2); + clock.Advance(.25); + recording.Opacity.ShouldBeInRange(.01, .99); + rainbow.Opacity.ShouldBeInRange(.01, .99); + (recording.Opacity + rainbow.Opacity).ShouldBe(1, .0001); + var recOpacity = recording.Opacity; + var rainbowOpacity = rainbow.Opacity; + hud.UpdateState(DictationState.Listening, .8f, "again"); + recording.Opacity.ShouldBe(recOpacity, .0001); + rainbow.Opacity.ShouldBe(rainbowOpacity, .0001); + clock.Advance(.25); + clock.Advance(.45); + recording.Opacity.ShouldBe(1, .0001); + rainbow.Opacity.ShouldBe(0, .0001); + hud.UpdateState(DictationState.Finishing, 0, "again"); + hud.UpdateState(DictationState.ErrorState("device lost"), 0, "again"); + Dispatcher.UIThread.RunJobs(); + recording.Opacity.ShouldBe(0); + rainbow.Opacity.ShouldBe(0); + hud.IsRainbowAnimating.ShouldBeFalse(); + Field(hud, "_processingTimer").ShouldBeNull(); + Field(hud, "_contentRevealTimer").ShouldBeNull(); + Part(hud, "TranscriptText").Text!.ShouldContain("device lost"); + Part(hud, "HudContentSurface").Opacity.ShouldBeGreaterThan(0); + } + finally { hud.Close(); } + } + + [AvaloniaFact] + public void Lifecycle_StopsLoopsOnStartingIdleErrorHiddenAndClose() + { + foreach (var ending in new[] { "starting", "idle", "error", "hidden", "close" }) + { + var hud = new HudWindow { IsReducedMotion = () => false }; + try + { + hud.UpdateState(DictationState.Listening, .8f, "words"); + Dispatcher.UIThread.RunJobs(); + var meter = Part(hud, "HudMeter"); + meter.StepBallistics(.09); + var peak = meter.CurrentPosition; + hud.UpdateState(DictationState.Finishing, 0, "words"); + meter.IsActive.ShouldBeFalse(); + meter.StepBallistics(.4); + meter.CurrentPosition.ShouldBeLessThan(peak * .06); + Field(hud, "_processingTimer").ShouldBeOfType().IsEnabled.ShouldBeTrue(); + hud.IsRainbowAnimating.ShouldBeTrue(); + switch (ending) + { + case "starting": hud.UpdateState(DictationState.Starting, 0, ""); break; + case "idle": hud.UpdateState(DictationState.Idle, 0, ""); break; + case "error": hud.UpdateState(DictationState.ErrorState("failed"), 0, ""); break; + case "hidden": hud.Hide(); break; + case "close": hud.Close(); break; + } + var angle = ((ConicGradientBrush)Part(hud, "RainbowRim").Resources["HudRainbowBrush"]!).Angle; + AvaloniaHeadlessPlatform.ForceRenderTimerTick(3); + Dispatcher.UIThread.RunJobs(); + hud.IsRainbowAnimating.ShouldBeFalse(); + Field(hud, "_rimFrameRequested").ShouldBe(false); + Field(hud, "_processingTimer").ShouldBeNull(); + meter.IsAnimationRunning.ShouldBeFalse(); + meter.CurrentPosition.ShouldBe(0); + Part(hud, "ProcessingDots").IsVisible.ShouldBeFalse(); + ((ConicGradientBrush)Part(hud, "RainbowRim").Resources["HudRainbowBrush"]!).Angle.ShouldBe(angle); + if (ending is "error" or "hidden" or "close") + { + Field(hud, "_contentRevealTimer").ShouldBeNull(); + Field(hud, "_hideTimer").ShouldBeNull(); + } + } + finally { hud.Close(); } + } + } + + [AvaloniaFact] + public void MotionPreferenceChange_DuringFinishingStopsDotsRotationAndTransitions() + { + var reduced = false; + var reads = 0; + var hud = new HudWindow { IsReducedMotion = () => { reads++; return reduced; } }; + try + { + hud.UpdateState(DictationState.Finishing, 0, "words"); + Dispatcher.UIThread.RunJobs(); + var initialReads = reads; + AvaloniaHeadlessPlatform.ForceRenderTimerTick(3); + Dispatcher.UIThread.RunJobs(); + reads.ShouldBe(initialReads); + reduced = true; + hud.UpdateState(DictationState.Finishing, 0, "words"); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(3); + Dispatcher.UIThread.RunJobs(); + var rim = Part(hud, "RainbowRim"); + rim.Opacity.ShouldBe(1); + rim.Transitions.ShouldBeNull(); + hud.IsRainbowAnimating.ShouldBeFalse(); + Field(hud, "_rimFrameRequested").ShouldBe(false); + Field(hud, "_processingTimer").ShouldBeNull(); + Field(hud, "_contentRevealTimer").ShouldBeNull(); + Part(hud, "HudContentSurface").Opacity.ShouldBe(1); + ((ConicGradientBrush)rim.Resources["HudRainbowBrush"]!).Angle.ShouldBe(-90); + reduced = false; + hud.UpdateState(DictationState.Finishing, 0, "words"); + rim.Transitions.ShouldNotBeNull(); + hud.IsRainbowAnimating.ShouldBeTrue(); + Field(hud, "_processingTimer").ShouldBeOfType().IsEnabled.ShouldBeTrue(); + } + finally { hud.Close(); } + } + + [AvaloniaFact] + public void SettingsMeter_KeepsLegacyBallisticsAndTimerWhileHudReducedMotionSnaps() + { + var settings = new VUMeterControl { ReducedMotion = true, IsActive = true, Level = .75f }; + var window = new Window { Content = settings }; + try + { + window.Show(); + settings.IsAnimationRunning.ShouldBeTrue(); + settings.CurrentPosition.ShouldBe(0); + typeof(VUMeterControl).GetMethod("AdvanceNeedle", BindingFlags.NonPublic | BindingFlags.Instance)!.Invoke(settings, null); + settings.CurrentPosition.ShouldBe(.288, .000001); + settings.HoldRemainingSeconds.ShouldBe(0); + var hud = new VUMeterControl { UseHudPalette = true, ReducedMotion = true, IsActive = true, Level = .75f }; + hud.CurrentPosition.ShouldBe(.75); + hud.Level = .25f; + hud.CurrentPosition.ShouldBe(.25); + hud.HoldRemainingSeconds.ShouldBe(0); + hud.IsAnimationRunning.ShouldBeFalse(); + } + finally { window.Close(); } + } + + [AvaloniaFact] + public void RecordingAndFinishing_ShareHeightRadiusAndRimGeometry() + { + var hud = new HudWindow { IsReducedMotion = () => true }; + try + { + hud.UpdateState(DictationState.Listening, .7f, "words"); + var surface = Part(hud, "HudSurface"); + var radius = surface.CornerRadius; + surface.Height.ShouldBe(44); + hud.UpdateState(DictationState.Finishing, 0, "words"); + surface.Height.ShouldBe(44); + surface.CornerRadius.ShouldBe(radius); + Part(hud, "HudContentSurface").Height.ShouldBe(44); + var recording = Part(hud, "RecordingRim"); + var rainbow = Part(hud, "RainbowRim"); + recording.ShouldNotBeNull(); + recording.Margin.ShouldBe(rainbow.Margin); + recording.Children.OfType().Select(b => b.BorderThickness) + .ShouldBe(rainbow.Children.OfType().Select(b => b.BorderThickness)); + } + finally { hud.Close(); } + } + + [AvaloniaFact] + public void ReducedMotion_UsesImmediatePropertiesAndNoMeterDotsOrRimLoops() + { + var hud = new HudWindow { IsReducedMotion = () => true }; + try + { + foreach (var state in new[] { DictationState.Starting, DictationState.Listening, DictationState.Finishing }) + { + hud.UpdateState(state, .75f, "words"); + Dispatcher.UIThread.RunJobs(); + var meter = Part(hud, "HudMeter"); + meter.IsAnimationRunning.ShouldBeFalse(); + meter.CurrentPosition.ShouldBe(state.Status == DictationStatus.Listening ? .75 : 0); + meter.HoldRemainingSeconds.ShouldBe(0); + Part(hud, "RecordingRim").Opacity.ShouldBe(state.Status == DictationStatus.Listening ? 1 : 0); + Part(hud, "RainbowRim").Opacity.ShouldBe(state.Status == DictationStatus.Finishing ? 1 : 0); + Part(hud, "HudSurface").Opacity.ShouldBe(1); + Part(hud, "HudContentSurface").Opacity.ShouldBe(1); + Part(hud, "HudSurface").Transitions.ShouldBeNull(); + Part(hud, "HudContentSurface").Transitions.ShouldBeNull(); + Part(hud, "TranscriptText").Transitions.ShouldBeNull(); + Part(hud, "RecordingRim").Transitions.ShouldBeNull(); + Part(hud, "RainbowRim").Transitions.ShouldBeNull(); + Field(hud, "_processingTimer").ShouldBeNull(); + Field(hud, "_contentRevealTimer").ShouldBeNull(); + Field(hud, "_rimFrameRequested").ShouldBe(false); + } + var brush = (ConicGradientBrush)Part(hud, "RainbowRim").Resources["HudRainbowBrush"]!; + var dots = DotNames + .Select(name => Part(hud, name)).ToArray(); + var opacities = dots.Select(d => d.Opacity).ToArray(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(5); + Dispatcher.UIThread.RunJobs(); + brush.Angle.ShouldBe(-90); + dots.Select(d => d.Opacity).ShouldBe(opacities); + hud.UpdateState(DictationState.Idle, 0, ""); + hud.IsVisible.ShouldBeFalse(); + Field(hud, "_hideTimer").ShouldBeNull(); + } + finally { hud.Close(); } + } + + [AvaloniaFact] + public void HudBallistics_AttackHoldReleaseAreMonotoneAndTimeStepIndependent() + { + var meter = new VUMeterControl { UseHudPalette = true, IsActive = true, Level = .8f }; + meter.StepBallistics(.035); + meter.CurrentPosition.ShouldBeInRange(.75, .8); + var peak = meter.CurrentPosition; + meter.Level = 0; + meter.StepBallistics(.020); + meter.CurrentPosition.ShouldBe(peak); + meter.HoldRemainingSeconds.ShouldBe(.020, .000001); + var split = new VUMeterControl { UseHudPalette = true, IsActive = true, Level = .8f }; + split.StepBallistics(.035); + split.Level = 0; + split.StepBallistics(.040); + split.StepBallistics(.02); + meter.StepBallistics(.040); + meter.CurrentPosition.ShouldBe(split.CurrentPosition, .000001); + var previous = meter.CurrentPosition; + for (var i = 0; i < 100; i++) + { + meter.StepBallistics(.016); + meter.CurrentPosition.ShouldBeInRange(0, previous); + previous = meter.CurrentPosition; + } + meter.CurrentPosition.ShouldBe(0); + } + + [AvaloniaFact] + public void HudBallistics_FollowsShortSyllablesInsteadOfHoldingOldPeak() + { + var meter = new VUMeterControl { UseHudPalette = true, IsActive = true }; + for (var syllable = 0; syllable < 3; syllable++) + { + meter.Level = .8f; + meter.StepBallistics(.035); + meter.CurrentPosition.ShouldBeGreaterThan(.75); + meter.Level = 0; + meter.StepBallistics(.160); + meter.CurrentPosition.ShouldBeLessThan(.09); + } + } + + [AvaloniaFact] + public void HudBallistics_NewPeakInterruptsHoldAndInactiveReleasesWithoutSecondHold() + { + var meter = new VUMeterControl { UseHudPalette = true, IsActive = true, Level = .6f }; + meter.StepBallistics(.09); + meter.Level = 0; + meter.StepBallistics(.05); + var held = meter.CurrentPosition; + meter.Level = 1; + meter.StepBallistics(.03); + meter.CurrentPosition.ShouldBeGreaterThan(held); + var peak = meter.CurrentPosition; + meter.IsActive = false; + meter.StepBallistics(.4); + meter.CurrentPosition.ShouldBeLessThan(peak * .06); + meter.HoldRemainingSeconds.ShouldBe(0); + meter.StepBallistics(double.NaN); + meter.StepBallistics(-1); + double.IsFinite(meter.CurrentPosition).ShouldBeTrue(); + } + + [AvaloniaFact] + public void StudioTheme_ReusesRecordRedAndPreservesAmberError() + { + foreach (var theme in new[] { ThemeVariant.Dark, ThemeVariant.Light }) + { + var hud = new HudWindow { RequestedThemeVariant = theme, IsReducedMotion = () => true }; + try + { + hud.UpdateState(DictationState.Listening, .5f, "words"); + var lamp = Part(hud, "RecordLamp"); + lamp.Parent.ShouldBeOfType().BorderThickness.Left.ShouldBeGreaterThan(0); + var rim = Part(hud, "RecordingRim"); + rim.Children.OfType().Last().BorderBrush.ShouldBe(lamp.Fill); + hud.TryFindResource("HudSpecularHairlineBrush", theme, out var edge).ShouldBeTrue(); + Part(hud, "HudSurface").BorderBrush.ShouldBe(edge); + var gradient = edge.ShouldBeAssignableTo(); + gradient!.GradientStops![0].Color.A.ShouldBeGreaterThan(gradient.GradientStops[^1].Color.A); + hud.TryFindResource("HudSolidFallbackBrush", theme, out var fallback).ShouldBeTrue(); + var background = fallback.ShouldBeAssignableTo()!.Color; + var foreground = Part(hud, "TranscriptText").Foreground.ShouldBeAssignableTo()!.Color; + var bg = Luminance(background); + var fg = Luminance(foreground); + ((Math.Max(bg, fg) + .05) / (Math.Min(bg, fg) + .05)).ShouldBeGreaterThan(4.5); + hud.UpdateState(DictationState.Finishing, 0, "words"); + Part(hud, "HudSurface").BorderBrush.ShouldBe(edge); + hud.UpdateState(DictationState.ErrorState("device lost"), 0, "words"); + hud.TryFindResource("HudErrorBrush", theme, out var amber).ShouldBeTrue(); + Part(hud, "HudSurface").BorderBrush.ShouldBe(amber); + lamp.Fill.ShouldBe(amber); + Part(hud, "RainbowRim").Opacity.ShouldBe(0); + } + finally { hud.Close(); } + } + } + + private static double Luminance(Color color) + { + static double Linear(byte channel) + { + var value = channel / 255.0; + return value <= .04045 ? value / 12.92 : Math.Pow((value + .055) / 1.055, 2.4); + } + return .2126 * Linear(color.R) + .7152 * Linear(color.G) + .0722 * Linear(color.B); + } +} diff --git a/windows/tests/Murmur.Core.Tests/DictationAudioBoundsTests.cs b/windows/tests/Murmur.Core.Tests/DictationAudioBoundsTests.cs new file mode 100644 index 0000000..7c8dcfc --- /dev/null +++ b/windows/tests/Murmur.Core.Tests/DictationAudioBoundsTests.cs @@ -0,0 +1,151 @@ +using Murmur.Core.Abstractions; +using Murmur.Core.State; +using Shouldly; +using Xunit; + +namespace Murmur.Core.Tests; + +public partial class DictationControllerTests +{ + private sealed class EdgeHotkey : IHotkeySource + { + private bool _pressed; + public event Action? Pressed; + public event Action? Released; + public void Press() { if (!_pressed) { _pressed = true; Pressed?.Invoke(); } } + public void Release() { if (_pressed) { _pressed = false; Released?.Invoke(); } } + public void Start() { } + public void Stop() { } + public void Dispose() { } + } + + [Theory] + [InlineData(4800000)] + [InlineData(4800001)] + [InlineData(5000000)] + public async Task DurationLimitAutoFinishesAtExactBoundaryWithoutWaitingInCallback(int length) + { + var audio = new FakeAudioCapture { DelayStop = true }; + var keys = new EdgeHotkey(); + var transcriber = new SpyTranscriber(); + var injector = new FakeTextInjector(); + await using var controller = new DictationController(audio, keys, transcriber, injector, "fake"); + try + { + controller.Start(); keys.Press(); + audio.EmitChunk(new float[length]); + controller.State.Status.ShouldBe(DictationStatus.Finishing); + controller.State.Message.ShouldBe("RecordingDurationLimit"); + controller.SessionCompletion.IsCompleted.ShouldBeFalse(); + transcriber.TranscribeCalls.ShouldBe(0); + audio.EmitChunk(new float[20]); + audio.StopGate.TrySetResult(); + await controller.SessionCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + transcriber.LastSampleCount.ShouldBe(4800000); + injector.Injected.Count.ShouldBe(1); + controller.State.Message.ShouldBe("RecordingDurationLimit"); + // Match the production hook's existing key-down deduplication after automatic stop. + keys.Press(); + controller.State.Status.ShouldBe(DictationStatus.Error); + keys.Release(); keys.Press(); + controller.State.Status.ShouldBe(DictationStatus.Listening); + audio.EmitChunk(new float[1]); keys.Release(); + await controller.SessionCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + transcriber.LastSampleCount.ShouldBe(1); + } + finally { audio.StopGate.TrySetResult(); } + } + + [Fact] + public async Task StreamingFailureStopsCaptureOffCallbackAndPreservesIncompletePartial() + { + var audio = new FakeAudioCapture { DelayStop = true }; + var keys = new FakeHotkeySource(); + var transcriber = new FakeStreamingTranscriber("must not insert"); + var injector = new FakeTextInjector(); + await using var controller = new DictationController(audio, keys, transcriber, injector, "fake"); + try + { + controller.Start(); keys.Press(); transcriber.EmitPartial("unvollstaendig"); + transcriber.Fail(new InvalidOperationException("StreamingBacklogExceeded")); + controller.State.Status.ShouldBe(DictationStatus.Error); + controller.State.Message.ShouldBe("StreamingBacklogExceeded"); + controller.CurrentTranscript.ShouldBe("unvollstaendig"); + controller.SessionCompletion.IsCompleted.ShouldBeFalse(); + injector.Injected.ShouldBeEmpty(); + audio.StopGate.TrySetResult(); + await controller.SessionCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + controller.RequiresRestart.ShouldBeFalse(); + keys.Press(); controller.State.Status.ShouldBe(DictationStatus.Listening); + } + finally { audio.StopGate.TrySetResult(); } + } + + [Fact] + public async Task AcceptAudioExceptionCannotEscapeCaptureCallback() + { + var audio = new FakeAudioCapture(); + var keys = new FakeHotkeySource(); + var transcriber = new FakeStreamingTranscriber("must not insert") { AcceptFailure = new IOException("queue failed") }; + var injector = new FakeTextInjector(); + await using var controller = new DictationController(audio, keys, transcriber, injector, "fake"); + controller.Start(); keys.Press(); + Should.NotThrow(() => audio.EmitChunk(new float[1])); + await controller.SessionCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + controller.State.Status.ShouldBe(DictationStatus.Error); + injector.Injected.ShouldBeEmpty(); + } + + [Fact] + public async Task BelowDurationLimitKeepsListeningUntilRelease() + { + var audio = new FakeAudioCapture(); + var keys = new EdgeHotkey(); + var transcriber = new SpyTranscriber(); + await using var controller = new DictationController(audio, keys, transcriber, new FakeTextInjector(), "fake"); + controller.Start(); keys.Press(); + audio.EmitChunk(new float[4799999]); + controller.State.Status.ShouldBe(DictationStatus.Listening); + keys.Release(); + await controller.SessionCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + transcriber.LastSampleCount.ShouldBe(4799999); + controller.State.ShouldBe(DictationState.Idle); + } + + [Fact] + public async Task StreamingDurationAutoFinishDoesNotAcceptPostLimitSamples() + { + var audio = new FakeAudioCapture(); + var keys = new EdgeHotkey(); + var transcriber = new FakeStreamingTranscriber("complete to the limit"); + var injector = new FakeTextInjector(); + await using var controller = new DictationController(audio, keys, transcriber, injector, "fake"); + controller.Start(); keys.Press(); + var chunk = new float[16000]; + for (var i = 0; i < 300; i++) audio.EmitChunk(chunk); + audio.EmitChunk(chunk); + await controller.SessionCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + transcriber.AcceptedSamples.Count.ShouldBe(4800000); + injector.Injected.Count.ShouldBe(1); + controller.State.Message.ShouldBe("RecordingDurationLimit"); + } + + [Fact] + public async Task OldStreamingFailureCallbackCannotAbortNewSession() + { + var audio = new FakeAudioCapture(); + var keys = new FakeHotkeySource(); + var transcriber = new FakeStreamingTranscriber("next"); + await using var controller = new DictationController(audio, keys, transcriber, new FakeTextInjector(), "fake"); + controller.Start(); keys.Press(); + var oldCallback = transcriber.FailureCallback!; + transcriber.Fail(new InvalidOperationException("StreamingRecognitionFailed")); + await controller.SessionCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + keys.Press(); + oldCallback(new IOException("late old failure")); + controller.State.Status.ShouldBe(DictationStatus.Listening); + audio.EmitChunk(new float[1]); keys.Release(); + await controller.SessionCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + controller.State.ShouldBe(DictationState.Idle); + } +} diff --git a/windows/tests/Murmur.Core.Tests/DictationControllerTests.cs b/windows/tests/Murmur.Core.Tests/DictationControllerTests.cs index 44a91b4..9ab34ae 100644 --- a/windows/tests/Murmur.Core.Tests/DictationControllerTests.cs +++ b/windows/tests/Murmur.Core.Tests/DictationControllerTests.cs @@ -8,8 +8,52 @@ namespace Murmur.Core.Tests; -public class DictationControllerTests +public partial class DictationControllerTests { + [Fact] + public async Task RepairCancellationCannotInsertEvenWhenRepairerReturnsLateText() + { + var audio = new FakeAudioCapture(); + var hotkey = new FakeHotkeySource(); + var injector = new FakeTextInjector(); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + DictationController? controller = null; + Task? stopped = null; + var repairer = new FakeSpeechRepairer(_ => + { + stopped = controller!.StopAsync(); + cancelled.TrySetResult(); + return "late repair"; + }); + await using var owner = controller = new DictationController(audio, hotkey, + new FakeTranscriber("original speech"), injector, "fake", speechRepairer: repairer); + owner.Start(); + hotkey.Press(); + audio.EmitChunk(new float[160]); + hotkey.Release(); + await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await stopped!.WaitAsync(TimeSpan.FromSeconds(5)); + injector.Injected.ShouldBeEmpty(); + } + + [Fact] + public async Task RepairMayLegitimatelyRemoveLongFalseStartWithoutLengthHeuristic() + { + var audio = new FakeAudioCapture(); + var hotkey = new FakeHotkeySource(); + var injector = new FakeTextInjector(); + using var controller = new DictationController(audio, hotkey, + new FakeTranscriber(new string('x', 1200)), injector, "fake", + speechRepairer: new FakeSpeechRepairer(_ => "Mittwoch")); + controller.Start(); + hotkey.Press(); + audio.EmitChunk(new float[160]); + hotkey.Release(); + await WaitForStatusAsync(controller, DictationStatus.Idle); + injector.Injected.Single().ShouldBe("Mittwoch."); + injector.InjectionMetadata.Single().Target.ShouldBe(injector.CapturedTarget); + } + private static async Task WaitForStatusAsync(DictationController controller, DictationStatus status) { if (controller.State.Status == status) @@ -57,6 +101,7 @@ private sealed class FakeAudioCapture : IAudioCapture public TaskCompletionSource StopGate { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public bool DelayStop { get; set; } + public int DisposeCalls { get; private set; } public void Start() { @@ -89,7 +134,7 @@ public void Fail(Exception exception) IsRunning = false; CaptureFailed?.Invoke(exception); } - public void Dispose() { } + public void Dispose() => DisposeCalls++; } private sealed class FakeHotkeySource : IHotkeySource @@ -123,19 +168,25 @@ private sealed class FakeStreamingTranscriber : IStreamingTranscriber public List AcceptedSamples { get; } = []; public bool SessionActive { get; private set; } public int CancelCalls { get; private set; } + public Exception? AcceptFailure { get; set; } + private Action? _onFailure; + public Action? FailureCallback => _onFailure; + public void Fail(Exception failure) => _onFailure?.Invoke(failure); public FakeStreamingTranscriber(string finalText) { _finalText = finalText; } - public void BeginSession() + public void BeginSession(Action? onFailure = null) { + _onFailure = onFailure; SessionActive = true; } public void AcceptAudio(ReadOnlyMemory audioSamples) { + if (AcceptFailure != null) throw AcceptFailure; AcceptedSamples.AddRange(audioSamples.Span); } @@ -625,6 +676,7 @@ public async Task Capture_Start_Error_Cancels_Streaming_Session() await WaitForStatusAsync(controller, DictationStatus.Error); controller.State.Status.ShouldBe(DictationStatus.Error); + await controller.SessionCompletion; transcriber.SessionActive.ShouldBeFalse(); transcriber.CancelCalls.ShouldBe(1); } @@ -649,6 +701,7 @@ public async Task Unexpected_Capture_Failure_Enters_Error_And_Next_Press_Restart controller.State.Message.ShouldNotBeNull(); controller.State.Message.ShouldContain("device invalidated"); + await controller.SessionCompletion; hotkey.Press(); controller.State.Status.ShouldBe(DictationStatus.Listening); } diff --git a/windows/tests/Murmur.Core.Tests/DictationLifecycleTests.cs b/windows/tests/Murmur.Core.Tests/DictationLifecycleTests.cs new file mode 100644 index 0000000..43a2044 --- /dev/null +++ b/windows/tests/Murmur.Core.Tests/DictationLifecycleTests.cs @@ -0,0 +1,165 @@ +using System.Collections.Concurrent; +using Murmur.Core.Abstractions; +using Murmur.Core.State; +using Shouldly; +using Xunit; + +namespace Murmur.Core.Tests; + +public partial class DictationControllerTests +{ + private sealed class ManualDeadlines : IDisposable + { + internal sealed record Request(Task Work, TimeSpan Budget, TaskCompletionSource Expired); + private readonly ConcurrentQueue _requests = new(); + private readonly SemaphoreSlim _available = new(0); + public async Task WaitAsync(Task work, TimeSpan budget, CancellationToken cancellationToken) + { + if (work.IsCompleted) { await work; return; } + var expired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _requests.Enqueue(new Request(work, budget, expired)); + _available.Release(); + var completed = await Task.WhenAny(work, expired.Task).WaitAsync(cancellationToken); + if (completed == expired.Task) throw new TimeoutException(); + await work; + } + public void Dispose() => _available.Dispose(); + public async Task NextAsync() + { + (await _available.WaitAsync(TimeSpan.FromSeconds(5))).ShouldBeTrue(); + _requests.TryDequeue(out var request).ShouldBeTrue(); + return request!; + } + } + + private sealed class BlockedTranscriber : ITranscriber + { + public TaskCompletionSource Returned { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int Calls { get; private set; } + public int DisposeCalls { get; private set; } + public Task TranscribeAsync(ReadOnlyMemory samples, CancellationToken cancellationToken = default) + { + Calls++; + Entered.TrySetResult(); + return Returned.Task; + } + public void Dispose() + { + Returned.Task.IsCompleted.ShouldBeTrue("native owner must finish before disposal"); + DisposeCalls++; + } + } + + [Theory] + [InlineData(false, 160, 60)] + [InlineData(true, 160, 60)] + [InlineData(false, 960000, 120)] + public async Task DecodeDeadlineOrStopBudgetQuarantinesUntilRealCompletion(bool stopFirst, int samples, int seconds) + { + var audio = new FakeAudioCapture(); + var hotkey = new FakeHotkeySource(); + var transcriber = new BlockedTranscriber(); + var repairer = new FakeSpeechRepairer(); + var injector = new FakeTextInjector(); + using var deadlines = new ManualDeadlines(); + var controller = new DictationController(audio, hotkey, transcriber, injector, "fake", speechRepairer: repairer) + { WaitForWorkAsync = deadlines.WaitAsync }; + try + { + controller.Start(); + hotkey.Press(); + audio.EmitChunk(new float[samples]); + hotkey.Release(); + var inference = await deadlines.NextAsync(); + if (inference.Budget == TimeSpan.FromSeconds(3)) + { + await inference.Work.WaitAsync(TimeSpan.FromSeconds(5)); + inference = await deadlines.NextAsync(); + } + inference.Budget.ShouldBe(TimeSpan.FromSeconds(seconds)); + await transcriber.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + if (stopFirst) + { + var stop = controller.StopAsync(); + var stopDeadline = await deadlines.NextAsync(); + stopDeadline.Budget.ShouldBe(TimeSpan.FromSeconds(3)); + stopDeadline.Expired.SetResult(); + await Should.ThrowAsync(() => stop); + } + else + { + inference.Expired.SetResult(); + await WaitForStatusAsync(controller, DictationStatus.Error); + } + controller.RequiresRestart.ShouldBeTrue(); + controller.State.Message.ShouldBe("RecognitionRestartRequired"); + hotkey.Press(); + transcriber.Calls.ShouldBe(1); + var disposal = controller.DisposeAsync().AsTask(); + // Dispose has its own public deadline; the actual stop/cleanup task remains owned. + var pending = await deadlines.NextAsync(); + pending.Expired.SetResult(); + if (!stopFirst) + { + pending = await deadlines.NextAsync(); + pending.Expired.SetResult(); + } + await Should.ThrowAsync(() => disposal); + transcriber.DisposeCalls.ShouldBe(0); + audio.DisposeCalls.ShouldBe(0); + repairer.IsDisposed.ShouldBeFalse(); + transcriber.Returned.SetResult("late text"); + await controller.DisposalCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + transcriber.DisposeCalls.ShouldBe(1); + audio.DisposeCalls.ShouldBe(1); + repairer.IsDisposed.ShouldBeTrue(); + injector.Injected.ShouldBeEmpty(); + controller.State.Status.ShouldBe(DictationStatus.Error); + } + finally + { + transcriber.Returned.TrySetResult("late text"); + controller.WaitForWorkAsync = static (work, budget, cancellation) => work.WaitAsync(budget, cancellation); + try { await controller.DisposeAsync(); } catch (TimeoutException) { } + await controller.DisposalCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + } + } + + [Fact] + public async Task CaptureStopIsAlsoOwnedUntilActualCompletion() + { + var audio = new FakeAudioCapture { DelayStop = true }; + var hotkey = new FakeHotkeySource(); + var transcriber = new SpyTranscriber(); + using var deadlines = new ManualDeadlines(); + var controller = new DictationController(audio, hotkey, transcriber, new FakeTextInjector(), "fake") + { WaitForWorkAsync = deadlines.WaitAsync }; + try + { + controller.Start(); hotkey.Press(); audio.EmitChunk(new float[160]); hotkey.Release(); + var captureStop = await deadlines.NextAsync(); + captureStop.Budget.ShouldBe(TimeSpan.FromSeconds(3)); + captureStop.Expired.SetResult(); + await WaitForStatusAsync(controller, DictationStatus.Error); + var disposal = controller.DisposeAsync().AsTask(); + (await deadlines.NextAsync()).Expired.SetResult(); + (await deadlines.NextAsync()).Expired.SetResult(); + await Should.ThrowAsync(() => disposal); + audio.DisposeCalls.ShouldBe(0); + transcriber.DisposeCalls.ShouldBe(0); + audio.StopGate.SetResult(); + await controller.DisposalCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + audio.DisposeCalls.ShouldBe(1); + transcriber.DisposeCalls.ShouldBe(1); + transcriber.TranscribeCalls.ShouldBe(0); + } + finally + { + audio.StopGate.TrySetResult(); + controller.WaitForWorkAsync = static (work, budget, cancellation) => work.WaitAsync(budget, cancellation); + try { await controller.DisposeAsync(); } catch (TimeoutException) { } + await controller.DisposalCompletion.WaitAsync(TimeSpan.FromSeconds(5)); + } + } +} diff --git a/windows/tests/Murmur.Platform.Windows.Tests/WasapiAudioCaptureTests.cs b/windows/tests/Murmur.Platform.Windows.Tests/WasapiAudioCaptureTests.cs index eb82e88..58423d7 100644 --- a/windows/tests/Murmur.Platform.Windows.Tests/WasapiAudioCaptureTests.cs +++ b/windows/tests/Murmur.Platform.Windows.Tests/WasapiAudioCaptureTests.cs @@ -8,6 +8,30 @@ namespace Murmur.Platform.Windows.Tests; public class WasapiAudioCaptureTests { + [Fact] + public async Task StopWaitsForWholeStoppedCallbackEvenAfterRunningFlagCleared() + { + using var capture = new WasapiAudioCapture(); + using var release = new ManualResetEventSlim(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var flags = BindingFlags.NonPublic | BindingFlags.Instance; + typeof(WasapiAudioCapture).GetField("_stopped", flags)!.SetValue(capture, stopped); + capture.CaptureFailed += _ => { entered.TrySetResult(); release.Wait(); }; + var callback = Task.Run(() => typeof(WasapiAudioCapture).GetMethod("OnRecordingStopped", flags)! + .Invoke(capture, [null, new StoppedEventArgs(new IOException("fake stopped"))])); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + capture.IsRunning.ShouldBeFalse(); + var stop = capture.StopAsync(); + stop.IsCompleted.ShouldBeFalse(); + release.Set(); + await Task.WhenAll(stop, callback).WaitAsync(TimeSpan.FromSeconds(5)); + } + finally { release.Set(); await callback.WaitAsync(TimeSpan.FromSeconds(5)); } + } + [Theory] [InlineData(short.MinValue, -1f)] [InlineData(0, 0f)] diff --git a/windows/tests/Murmur.Speech.Tests/NativeSessionLifetimeTests.cs b/windows/tests/Murmur.Speech.Tests/NativeSessionLifetimeTests.cs new file mode 100644 index 0000000..3a1dfa4 --- /dev/null +++ b/windows/tests/Murmur.Speech.Tests/NativeSessionLifetimeTests.cs @@ -0,0 +1,218 @@ +using Shouldly; +using Xunit; +using System.Reflection; +using Murmur.Core; +using Murmur.Core.Abstractions; +using Murmur.Core.Settings; +using Murmur.Core.State; + +namespace Murmur.Speech.Tests; + +[Collection("Native session lifetime")] +public class NativeSessionLifetimeTests +{ + private sealed class Audio : IAudioCapture + { + public event Action>? AudioChunkAvailable; + public event Action? LevelChanged { add { } remove { } } + public event Action? CaptureFailed { add { } remove { } } + public bool IsRunning { get; private set; } + public AudioCaptureFormat? Format => null; + public AudioCaptureStats? LastSessionStats => null; + public void Start() => IsRunning = true; + public void Emit() => AudioChunkAvailable?.Invoke(new float[160]); + public void Emit(float[] samples) => AudioChunkAvailable?.Invoke(samples); + public Task StopAsync(CancellationToken cancellationToken = default) { IsRunning = false; return Task.CompletedTask; } + public void Dispose() { } + } + private sealed class Keys : IHotkeySource + { + public event Action? Pressed; + public event Action? Released; + public void Press() => Pressed?.Invoke(); + public void Release() => Released?.Invoke(); + public void Start() { } + public void Stop() { } + public void Dispose() { } + } + private sealed class Injector : ITextInjector + { + public int Insertions { get; private set; } + public TextInjectionTarget CaptureTarget() => new(1, 2); + public void Insert(string text, TextInjectionTarget target, TextInsertionMode mode, bool restorePreviousWindow = false) + => Insertions++; + } + + [Fact] + public async Task BacklogStopsCaptureAndQuarantinesBlockedReaderWithoutInsertingPartial() + { + using var release = new ManualResetEventSlim(); + var blocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var partial = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var restart = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ready = false; + var chunks = 0; + var disposals = 0; + var native = new NemotronStreamingTranscriber(() => new( + _ => { chunks++; ready = true; }, () => ready, + () => { if (chunks == 2) { blocked.SetResult(); release.Wait(); } ready = false; }, + () => "incomplete partial", () => { }, () => disposals++), () => { }); + var audio = new Audio(); + var keys = new Keys(); + var injector = new Injector(); + var controller = new DictationController(audio, keys, native, injector, "fake"); + const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance; + Func wait = (work, budget, cancellation) => + blocked.Task.IsCompleted && budget == TimeSpan.FromSeconds(3) + ? Task.FromException(new TimeoutException()) : work.WaitAsync(cancellation); + typeof(DictationController).GetProperty("WaitForWorkAsync", flags)!.SetValue(controller, wait); + controller.TranscriptChanged += value => { if (value.Length > 0) partial.TrySetResult(); }; + controller.StateChanged += state => { if (state.Message == "RecognitionRestartRequired") restart.TrySetResult(); }; + try + { + controller.Start(); keys.Press(); audio.Emit(); + await partial.Task.WaitAsync(TimeSpan.FromSeconds(5)); + audio.Emit(); + await blocked.Task.WaitAsync(TimeSpan.FromSeconds(5)); + audio.Emit(new float[144001]); + await restart.Task.WaitAsync(TimeSpan.FromSeconds(5)); + controller.RequiresRestart.ShouldBeTrue(); + controller.CurrentTranscript.ShouldBe("incomplete partial"); + injector.Insertions.ShouldBe(0); + await Should.ThrowAsync(() => controller.DisposeAsync().AsTask()); + disposals.ShouldBe(0); + release.Set(); + await ((Task)typeof(DictationController).GetProperty("DisposalCompletion", flags)! + .GetValue(controller)!).WaitAsync(TimeSpan.FromSeconds(5)); + audio.IsRunning.ShouldBeFalse(); + disposals.ShouldBe(1); + chunks.ShouldBe(2); + } + finally + { + release.Set(); + try { await controller.DisposeAsync(); } catch (TimeoutException) { } + await ((Task)typeof(DictationController).GetProperty("DisposalCompletion", flags)! + .GetValue(controller)!).WaitAsync(TimeSpan.FromSeconds(5)); + } + } + + [Fact] + public async Task ControllerTimeoutRetainsNemotronFinalDecodeAndRecoveryUntilActualCleanup() + { + using var unblock = new ManualResetEventSlim(); + var finalEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var partialSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var timedOut = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deadline = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var final = false; + var ready = true; + var streamsDisposed = 0; + var recognizersDisposed = 0; + var transcriber = new NemotronStreamingTranscriber(() => new NemotronStreamingTranscriber.NativeSession( + _ => { }, () => ready, () => + { + if (final) { finalEntered.TrySetResult(); unblock.Wait(); } + ready = false; + }, () => "partial recovery", () => { final = true; ready = true; }, + () => Interlocked.Increment(ref streamsDisposed)), () => Interlocked.Increment(ref recognizersDisposed)); + var audio = new Audio(); + var keys = new Keys(); + var injector = new Injector(); + var controller = new DictationController(audio, keys, transcriber, injector, "fake"); + const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance; + async Task Wait(Task work, TimeSpan budget, CancellationToken cancellation) + { + if (budget == TimeSpan.FromSeconds(30)) + { + var winner = await Task.WhenAny(work, deadline.Task).WaitAsync(cancellation); + if (winner == deadline.Task) throw new TimeoutException(); + } + else if (budget == TimeSpan.FromSeconds(3) && deadline.Task.IsCompleted) throw new TimeoutException(); + await work.WaitAsync(cancellation); + } + typeof(DictationController).GetProperty("WaitForWorkAsync", flags)!.SetValue(controller, + (Func)Wait); + controller.TranscriptChanged += text => { if (text == "partial recovery") partialSeen.TrySetResult(); }; + controller.StateChanged += state => { if (state.Status == DictationStatus.Error) timedOut.TrySetResult(); }; + try + { + controller.Start(); keys.Press(); audio.Emit(); + await partialSeen.Task.WaitAsync(TimeSpan.FromSeconds(5)); + keys.Release(); + await finalEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + deadline.SetResult(); + await timedOut.Task.WaitAsync(TimeSpan.FromSeconds(5)); + controller.RequiresRestart.ShouldBeTrue(); + controller.CurrentTranscript.ShouldBe("partial recovery"); + await Should.ThrowAsync(() => controller.DisposeAsync().AsTask()); + streamsDisposed.ShouldBe(0); + recognizersDisposed.ShouldBe(0); + unblock.Set(); + await ((Task)typeof(DictationController).GetProperty("DisposalCompletion", flags)! + .GetValue(controller)!).WaitAsync(TimeSpan.FromSeconds(5)); + streamsDisposed.ShouldBe(1); + recognizersDisposed.ShouldBe(1); + injector.Insertions.ShouldBe(0); + } + finally + { + unblock.Set(); + try { await controller.DisposeAsync(); } catch (TimeoutException) { } + await ((Task)typeof(DictationController).GetProperty("DisposalCompletion", flags)! + .GetValue(controller)!).WaitAsync(TimeSpan.FromSeconds(5)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReaderOrFinalDecodeRetainsWholeNativeChainUntilActuallyReturned(bool finalDecode) + { + using var unblock = new ManualResetEventSlim(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var disposalStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var streamDisposals = 0; + var recognizerDisposals = 0; + var final = false; + var ready = true; + var transcriber = new NemotronStreamingTranscriber(() => new NemotronStreamingTranscriber.NativeSession( + _ => { }, () => ready && (!finalDecode || final), () => + { + entered.TrySetResult(); + unblock.Wait(); + ready = false; + }, () => "late result", () => final = true, () => Interlocked.Increment(ref streamDisposals)), + () => Interlocked.Increment(ref recognizerDisposals)); + Task? disposal = null; + try + { + transcriber.BeginSession(); + if (!finalDecode) transcriber.AcceptAudio(new float[160]); + using var cancellation = new CancellationTokenSource(); + var completion = transcriber.CompleteSessionAsync(cancellation.Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + await Should.ThrowAsync(() => completion); + Should.Throw(() => transcriber.BeginSession()); + var cancelled = transcriber.CancelSessionAsync(); + disposal = Task.Run(() => { disposalStarted.SetResult(); transcriber.Dispose(); }); + await disposalStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancelled.IsCompleted.ShouldBeFalse(); + streamDisposals.ShouldBe(0); + recognizerDisposals.ShouldBe(0); + unblock.Set(); + await Task.WhenAll(cancelled, disposal).WaitAsync(TimeSpan.FromSeconds(5)); + streamDisposals.ShouldBe(1); + recognizerDisposals.ShouldBe(1); + transcriber.Dispose(); + recognizerDisposals.ShouldBe(1); + } + finally + { + unblock.Set(); + if (disposal != null) await disposal.WaitAsync(TimeSpan.FromSeconds(5)); + transcriber.Dispose(); + } + } +} diff --git a/windows/tests/Murmur.Speech.Tests/SpeechRepairBudgetTests.cs b/windows/tests/Murmur.Speech.Tests/SpeechRepairBudgetTests.cs new file mode 100644 index 0000000..b5b3096 --- /dev/null +++ b/windows/tests/Murmur.Speech.Tests/SpeechRepairBudgetTests.cs @@ -0,0 +1,113 @@ + +using Murmur.Core.Abstractions; +using Shouldly; +using Xunit; + +namespace Murmur.Speech.Tests; + +public class SpeechRepairBudgetTests +{ + private static async IAsyncEnumerable Chunks(IEnumerable chunks, Action? after = null) + { + await Task.CompletedTask; + foreach (var chunk in chunks) yield return chunk; + after?.Invoke(); + } + + [Theory] + [InlineData(1568, true)] + [InlineData(1569, false)] + [InlineData(2048, false)] + [InlineData(int.MaxValue, false)] + public async Task ExactFormattedPromptAndEntireReserveMustFit(int promptTokens, bool fits) + { + const string raw = " Original ohne Veraenderung "; + var prompts = new List(); + var calls = 0; + using var repairer = new LlamaSharpSpeechRepairer(text => + { + prompts.Add(text); + return text == raw ? 200 : promptTokens; + }, (prompt, maximum, ct) => + { + calls++; + maximum.ShouldBe(464); + prompt.ShouldStartWith("<|im_start|>system\n"); + prompt.ShouldEndWith("<|im_start|>assistant\n"); + prompt.ShouldContain(raw); + return Chunks(["kurz"]); + }); + (await repairer.RepairAsync(raw)).ShouldBe(fits ? "kurz" : raw); + calls.ShouldBe(fits ? 1 : 0); + prompts.Count.ShouldBe(2); + } + + [Fact] + public async Task LongOutputBeyondOld256LimitIsAcceptedBeforeDynamicBudget() + { + using var repairer = new LlamaSharpSpeechRepairer(_ => 200, + (_, maximum, _) => Chunks(Enumerable.Repeat("wort ", 300))); + (await repairer.RepairAsync("Original")).ShouldBe(string.Join(' ', Enumerable.Repeat("wort", 300))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FullBudgetIncludingEmptyUtf8YieldsKeepsExactOriginal(bool empty) + { + const string raw = " Original mit Rest "; + using var repairer = new LlamaSharpSpeechRepairer(_ => 200, + (_, maximum, _) => Chunks(Enumerable.Repeat(empty ? "" : "Teil", maximum))); + (await repairer.RepairAsync(raw)).ShouldBe(raw); + } + + [Theory] + [InlineData("Mittwoch")] + [InlineData("am Mittwoch um")] + public async Task LegitimateStrongCorrectionAndFragmentNeedNoLengthOrPunctuationHeuristic(string corrected) + { + using var repairer = new LlamaSharpSpeechRepairer(_ => 200, (_, _, _) => Chunks([corrected])); + (await repairer.RepairAsync(new string('x', 500))).ShouldBe(corrected); + } + + [Fact] + public async Task InferenceFailureOrEmptyOutputKeepsOriginal() + { + foreach (var fails in new[] { true, false }) + { + using var repairer = new LlamaSharpSpeechRepairer(_ => 200, + (_, _, _) => fails ? throw new InvalidOperationException("decoder failed") : Chunks([" "])); + (await repairer.RepairAsync(" original ")).ShouldBe(" original "); + } + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("lang")] + public async Task CancellationWinsOverEarlyReturnsAndMissingModelFallback(string raw) + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + using var repairer = new LlamaSharpSpeechRepairer(null); + await Should.ThrowAsync(() => repairer.RepairAsync(raw, cancellation.Token)); + } + + [Fact] + public async Task SilentExecutorCancellationAfterEnumerationCannotReturnPartial() + { + using var cancellation = new CancellationTokenSource(); + using var repairer = new LlamaSharpSpeechRepairer(_ => 200, + (_, _, _) => Chunks(["Teil"], cancellation.Cancel)); + await Should.ThrowAsync(() => repairer.RepairAsync("Original", cancellation.Token)); + } + + [Fact] + public async Task CancellationDuringTokenizationWinsOverContextFallback() + { + using var cancellation = new CancellationTokenSource(); + using var repairer = new LlamaSharpSpeechRepairer(_ => { cancellation.Cancel(); return int.MaxValue; }, + (_, _, _) => throw new InvalidOperationException("Must not infer")); + await Should.ThrowAsync(() => repairer.RepairAsync("Original", cancellation.Token)); + } +} diff --git a/windows/tests/Murmur.Speech.Tests/StreamingAudioBoundsTests.cs b/windows/tests/Murmur.Speech.Tests/StreamingAudioBoundsTests.cs new file mode 100644 index 0000000..b67ca76 --- /dev/null +++ b/windows/tests/Murmur.Speech.Tests/StreamingAudioBoundsTests.cs @@ -0,0 +1,160 @@ +using Shouldly; +using System.Buffers; +using Xunit; + +namespace Murmur.Speech.Tests; + +[Collection("Native session lifetime")] +public class StreamingAudioBoundsTests +{ + [Fact] + public async Task BatchOverNineSecondsWaitsForConsumerAndKeepsAllSamplesInOrder() + { + using var release = new ManualResetEventSlim(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var received = new List(); + var disposals = 0; + using var transcriber = new NemotronStreamingTranscriber(() => new( + samples => { if (received.Count == 0) { entered.TrySetResult(); release.Wait(); } received.AddRange(samples); }, + () => false, () => { }, () => "complete", () => { }, () => disposals++), () => { }); + var input = Enumerable.Range(0, 320001).Select(i => (float)i).ToArray(); + Task? result = null; + try + { + result = transcriber.TranscribeAsync(input); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + transcriber.PendingAudio.Samples.ShouldBeLessThanOrEqualTo(144000); + transcriber.PendingAudio.Items.ShouldBe(1); + result.IsCompleted.ShouldBeFalse(); + release.Set(); + (await result.WaitAsync(TimeSpan.FromSeconds(5))).ShouldBe("complete"); + received.ShouldBe(input); + disposals.ShouldBe(1); + } + finally { release.Set(); if (result != null) try { await result; } catch { } } + } + + [Fact] + public async Task CancelledBatchRetainsNativeOwnerUntilConsumerReturns() + { + using var release = new ManualResetEventSlim(); + using var cancel = new CancellationTokenSource(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var disposals = 0; + using var transcriber = new NemotronStreamingTranscriber(() => new( + _ => { entered.TrySetResult(); release.Wait(); }, () => false, () => { }, () => "late", () => { }, () => disposals++), () => { }); + Task? result = null; + try + { + result = transcriber.TranscribeAsync(new float[320001], cancel.Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancel.Cancel(); + result.IsCompleted.ShouldBeFalse(); + disposals.ShouldBe(0); + release.Set(); + await Should.ThrowAsync(() => result.WaitAsync(TimeSpan.FromSeconds(5))); + disposals.ShouldBe(1); + } + finally { release.Set(); if (result != null) try { await result; } catch { } } + } + + [Fact] + public async Task BatchWorkerFailurePropagatesAndAllowsNextSession() + { + var fail = true; + var received = 0; + using var transcriber = new NemotronStreamingTranscriber(() => new( + samples => { if (fail) { fail = false; throw new IOException("native failure"); } received += samples.Length; }, + () => false, () => { }, () => "next", () => { }, () => { }), () => { }); + var error = await Should.ThrowAsync(() => transcriber.TranscribeAsync(new float[320001])); + error.Message.ShouldBe("StreamingRecognitionFailed"); + (await transcriber.TranscribeAsync(new float[160])).ShouldBe("next"); + received.ShouldBe(160); + } + + private sealed class UnreadableMemory(int length) : MemoryManager + { + public Memory Oversized => CreateMemory(length); + public override Span GetSpan() => throw new InvalidOperationException("must check length before copy"); + public override MemoryHandle Pin(int elementIndex = 0) => throw new NotSupportedException(); + public override void Unpin() { } + protected override void Dispose(bool disposing) { } + } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OutstandingBudgetIncludesBlockedNativeItemAndRejectsNextChunk(bool tinyChunks) + { + using var release = new ManualResetEventSlim(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var failed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var disposed = 0; + var first = true; + using var transcriber = new NemotronStreamingTranscriber(() => new( + _ => { if (first) { first = false; entered.SetResult(); release.Wait(); } }, + () => false, () => { }, () => "", () => { }, () => disposed++), () => { }); + try + { + transcriber.BeginSession(ex => failed.TrySetResult(ex)); + transcriber.AcceptAudio(new float[1]); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + if (tinyChunks) + for (var i = 1; i < 1024; i++) transcriber.AcceptAudio(new float[1]); + else transcriber.AcceptAudio(new float[143999]); + transcriber.PendingAudio.ShouldBe(tinyChunks ? (1024, 1024) : (144000, 2)); + failed.Task.IsCompleted.ShouldBeFalse(); + transcriber.AcceptAudio(new float[1]); + (await failed.Task.WaitAsync(TimeSpan.FromSeconds(5))).Message.ShouldBe("StreamingBacklogExceeded"); + transcriber.PendingAudio.ShouldBe(tinyChunks ? (1024, 1024) : (144000, 2)); + var cleanup = transcriber.CancelSessionAsync(); + cleanup.IsCompleted.ShouldBeFalse(); + disposed.ShouldBe(0); + release.Set(); + await Should.ThrowAsync(() => cleanup); + disposed.ShouldBe(1); + transcriber.PendingAudio.ShouldBe((0, 0)); + transcriber.BeginSession(); + transcriber.AcceptAudio(new float[7]); + (await transcriber.CompleteSessionAsync()).ShouldBe(""); + transcriber.PendingAudio.ShouldBe((0, 0)); + } + finally { release.Set(); } + } + + [Theory] + [InlineData(144001)] + [InlineData(int.MaxValue)] + public async Task OversizedSingleChunkFailsBeforeEnqueueAndDoesNotReturnPartialSuccess(int length) + { + var failed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var accepts = 0; + using var transcriber = new NemotronStreamingTranscriber(() => new( + _ => accepts++, () => false, () => { }, () => "incomplete", () => { }, () => { }), () => { }); + transcriber.BeginSession(ex => failed.TrySetResult(ex)); + using var samples = new UnreadableMemory(length); + transcriber.AcceptAudio(samples.Oversized); + (await failed.Task.WaitAsync(TimeSpan.FromSeconds(5))).Message.ShouldBe("StreamingBacklogExceeded"); + transcriber.PendingAudio.ShouldBe((0, 0)); + await Should.ThrowAsync(() => transcriber.CompleteSessionAsync()); + accepts.ShouldBe(0); + } + + [Fact] + public async Task WorkerFailureSignalsImmediatelyWithoutCompleteAndNextSessionIsClean() + { + var failed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var throwOnce = true; + var accepts = 0; + using var transcriber = new NemotronStreamingTranscriber(() => new( + _ => { accepts++; if (throwOnce) { throwOnce = false; throw new IOException("native failure"); } }, + () => false, () => { }, () => "next", () => { }, () => { }), () => { }); + transcriber.BeginSession(ex => failed.TrySetResult(ex)); + transcriber.AcceptAudio(new float[3]); + (await failed.Task.WaitAsync(TimeSpan.FromSeconds(5))).Message.ShouldBe("StreamingRecognitionFailed"); + await Should.ThrowAsync(() => transcriber.CancelSessionAsync()); + transcriber.BeginSession(); + transcriber.AcceptAudio(new float[1]); + (await transcriber.CompleteSessionAsync()).ShouldBe("next"); + accepts.ShouldBe(2); + } +}