Skip to content
Merged
84 changes: 64 additions & 20 deletions windows/src/Murmur.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -332,19 +333,31 @@ 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;
DictationController? candidate = null;
var loadingTranscriber = false;
try
{
if (!IsCurrentLoad()) return;
if (!IsCurrentLoad() || _recognitionBlocked) return;
_profileToggleHook?.Dispose();
_profileToggleHook = null;
await DisposeControllerAsync();
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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));
}
}
}
Expand Down Expand Up @@ -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();
Expand All @@ -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();
}
}
}
87 changes: 82 additions & 5 deletions windows/src/Murmur.App/Controls/VUMeterControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using System.Diagnostics;

namespace Murmur.App.Controls;

/// <summary>
/// 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.
/// </summary>
public sealed class VUMeterControl : Control
{
Expand Down Expand Up @@ -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;

/// <summary>Internal flag indicating if animation timer is running (for tests only).</summary>
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"));
Expand Down Expand Up @@ -121,6 +137,9 @@ private double ResolveDouble(string key, double fallback)
private void OnPaletteModeChanged()
{
_paletteDirty = true;
_holdRemaining = 0;
_velocity = 0;
EvaluateTimerState();
InvalidateVisual();
}

Expand Down Expand Up @@ -204,7 +223,7 @@ private void OnLevelOrActiveChanged()
{
UpdateItemStatus();
EvaluateTimerState();
if (!_isAttached || !IsVisible)
if (!_isAttached || !IsVisible || (UseHudPalette && ReducedMotion))
{
InvalidateVisual();
}
Expand All @@ -223,14 +242,25 @@ 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();
return;
}

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)
{
Expand All @@ -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();
}
}
Expand Down Expand Up @@ -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();
}
}

/// <inheritdoc />
public override void Render(DrawingContext context)
{
Expand Down
26 changes: 26 additions & 0 deletions windows/src/Murmur.App/Diagnostics/ShutdownDeadline.cs
Original file line number Diff line number Diff line change
@@ -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<Task, TimeSpan, Task>? 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();
}
}
4 changes: 4 additions & 0 deletions windows/src/Murmur.App/Localization/UiTextResources.de.resx
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,8 @@
<data name="DictionaryTermExample" xml:space="preserve"><value>z. B. GitHub</value></data>
<data name="DictionaryCorrectionExample" xml:space="preserve"><value>z. B. cloud code</value></data>
<data name="Confirm" xml:space="preserve"><value>Bestätigen</value></data>
<data name="RecognitionRestartRequired" xml:space="preserve"><value>Die Spracherkennung konnte nicht sicher beendet werden. Bitte Murmur vor dem nächsten Diktat oder Modellwechsel neu starten. Geretteter Teiltext ist unvollständig.</value></data>
<data name="StreamingBacklogExceeded" xml:space="preserve"><value>Erkennung kam nicht hinterher. Aufnahme gestoppt; wiederhergestellter Text ist unvollständig. Bitte kürzer diktieren.</value></data>
<data name="StreamingRecognitionFailed" xml:space="preserve"><value>Streaming-Erkennung fehlgeschlagen. Aufnahme gestoppt; wiederhergestellter Text ist unvollständig. Bitte erneut versuchen.</value></data>
<data name="RecordingDurationLimit" xml:space="preserve"><value>Fünf-Minuten-Grenze erreicht. Aufnahme automatisch gestoppt. Ergebnis prüfen; danach gesprochene Wörter wurden nicht aufgenommen.</value></data>
</root>
4 changes: 4 additions & 0 deletions windows/src/Murmur.App/Localization/UiTextResources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,8 @@
<data name="DictionaryTermExample" xml:space="preserve"><value>e.g. GitHub</value></data>
<data name="DictionaryCorrectionExample" xml:space="preserve"><value>e.g. cloud code</value></data>
<data name="Confirm" xml:space="preserve"><value>Confirm</value></data>
<data name="RecognitionRestartRequired" xml:space="preserve"><value>Speech recognition did not finish safely. Please restart Murmur before dictating or changing models. Any recovered partial text is incomplete.</value></data>
<data name="StreamingBacklogExceeded" xml:space="preserve"><value>Recognition could not keep up. Recording stopped; recovered text is incomplete. Please try a shorter dictation.</value></data>
<data name="StreamingRecognitionFailed" xml:space="preserve"><value>Streaming recognition failed. Recording stopped; recovered text is incomplete. Please try again.</value></data>
<data name="RecordingDurationLimit" xml:space="preserve"><value>Five-minute limit reached. Recording stopped automatically. Check the result; words spoken after stopping were not recorded.</value></data>
</root>
Loading
Loading