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);
+ }
+}