From 6c5b6fd45cbb926a5870f126ab919aca80d42bad Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 9 Jul 2026 22:05:31 -0500 Subject: [PATCH 01/47] Add Whisper.net and NAudio dependencies for audio transcription Reference local Whisper (Whisper.net + Whisper.net.Runtime) and NAudio for on-device audio transcription, and declare the microphone device capability needed for live microphone capture. Co-Authored-By: Claude Opus 4.8 --- Text-Grab-Package/Package.appxmanifest | 2 ++ Text-Grab/Text-Grab.csproj | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/Text-Grab-Package/Package.appxmanifest b/Text-Grab-Package/Package.appxmanifest index 1d06c7de..bd13e961 100644 --- a/Text-Grab-Package/Package.appxmanifest +++ b/Text-Grab-Package/Package.appxmanifest @@ -157,5 +157,7 @@ + + diff --git a/Text-Grab/Text-Grab.csproj b/Text-Grab/Text-Grab.csproj index e00d2250..038524e0 100644 --- a/Text-Grab/Text-Grab.csproj +++ b/Text-Grab/Text-Grab.csproj @@ -78,10 +78,20 @@ + + + + + all From f1d51ae37ec07fc7e56ebe98960eb3a97668401d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 9 Jul 2026 22:05:37 -0500 Subject: [PATCH 02/47] Add AudioTranscriptionModel setting Persist the user's chosen Whisper model (defaults to balanced multilingual base), trading transcription speed for accuracy and language coverage. Co-Authored-By: Claude Opus 4.8 --- Text-Grab/App.config | 3 +++ Text-Grab/Properties/Settings.Designer.cs | 14 +++++++++++++- Text-Grab/Properties/Settings.settings | 3 +++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Text-Grab/App.config b/Text-Grab/App.config index a8541c8b..1421adeb 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -196,6 +196,9 @@ True + + BaseMultilingual + Auto diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index fc562c38..1c228831 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -778,7 +778,19 @@ public bool EtwNormalizeLineEndingsOnPaste { this["EtwNormalizeLineEndingsOnPaste"] = value; } } - + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("BaseMultilingual")] + public string AudioTranscriptionModel { + get { + return ((string)(this["AudioTranscriptionModel"])); + } + set { + this["AudioTranscriptionModel"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("Auto")] diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index 2b4519a9..22ca0fd2 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -191,6 +191,9 @@ True + + BaseMultilingual + Auto From 220f72a15faf71e4fa27ba908f50636e4d43618d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 9 Jul 2026 22:05:47 -0500 Subject: [PATCH 03/47] Add local Whisper audio transcription utilities Implement on-device transcription backed by Whisper.net (whisper.cpp), running on the CPU packaged or unpackaged on x64/arm64: - File transcription decodes any Media Foundation audio to 16 kHz mono via NAudio and streams Whisper segments as they are recognized, cancellably. - Selectable model (tiny.en / base.en / base / small) via WhisperModelChoice, read from settings; the shared WhisperFactory reloads when the choice changes. - VAD-gated live transcription (LiveAudioTranscriber) from microphone or system-audio loopback: Silero VAD finds speech regions and only sends a region to Whisper once trailing silence marks the utterance complete, so silence is skipped and phrases cut on natural boundaries. - AudioDebugLog writes a timestamped diagnostic log for the transcription path. Co-Authored-By: Claude Opus 4.8 --- .../Utilities/AudioTranscriptionUtilities.cs | 738 ++++++++++++++++++ 1 file changed, 738 insertions(+) create mode 100644 Text-Grab/Utilities/AudioTranscriptionUtilities.cs diff --git a/Text-Grab/Utilities/AudioTranscriptionUtilities.cs b/Text-Grab/Utilities/AudioTranscriptionUtilities.cs new file mode 100644 index 00000000..8ca0463b --- /dev/null +++ b/Text-Grab/Utilities/AudioTranscriptionUtilities.cs @@ -0,0 +1,738 @@ +using NAudio.CoreAudioApi; +using NAudio.MediaFoundation; +using NAudio.Utils; +using NAudio.Wave; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Whisper.net; +using Whisper.net.Ggml; + +namespace Text_Grab.Utilities; + +/// +/// Lightweight, always-on file logger for the audio transcription path. Writes timestamped lines +/// (with current process working set) to a stable, easy-to-find location so a run can be diagnosed +/// after the fact. Also mirrors to . +/// +public static class AudioDebugLog +{ + private static readonly object _lock = new(); + + /// Fixed, non-virtualized log path so it's findable after a run. + public static string LogPath { get; } = Path.Combine( + Environment.GetEnvironmentVariable("USERPROFILE") ?? Path.GetTempPath(), + "TextGrab-audio-debug.log"); + + public static void Write(string message) + { + long workingSetMb = 0; + try { workingSetMb = Process.GetCurrentProcess().WorkingSet64 / (1024 * 1024); } catch { } + + string line = $"{DateTime.Now:HH:mm:ss.fff} [WS {workingSetMb,6} MB] {message}"; + Debug.WriteLine("[AudioTranscription] " + line); + try + { + lock (_lock) + File.AppendAllText(LogPath, line + Environment.NewLine); + } + catch { /* logging must never throw */ } + } +} + +/// +/// On-device audio transcription backed by local Whisper (whisper.cpp) models via Whisper.net. +/// Runs entirely on the CPU, works packaged or unpackaged on x64 and arm64, and does not depend on +/// any experimental OS runtime. Arbitrary audio is decoded/resampled to the 16 kHz mono WAV that +/// Whisper requires using NAudio's Media Foundation reader/resampler. +/// +public static class AudioTranscriptionUtilities +{ + private static readonly HashSet AudioExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".oga", ".opus", ".wma", ".mp4", ".mov", + }; + + private static WhisperFactory? _whisperFactory; + private static WhisperModelChoice _loadedModelChoice; + private static readonly SemaphoreSlim _factoryLock = new(1, 1); + + // Silero VAD (voice activity detection) lets live transcription skip silence and cut on natural + // speech boundaries instead of fixed time windows. The factory is small and shared. + private static WhisperVadFactory? _vadFactory; + private static readonly SemaphoreSlim _vadFactoryLock = new(1, 1); + + private static string ModelDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Text-Grab", "WhisperModels"); + + /// The transcription model currently selected in settings (defaults to multilingual base). + public static WhisperModelChoice CurrentModelChoice => WhisperModelInfo.Parse(AppUtilities.TextGrabSettings.AudioTranscriptionModel); + + private static string ModelPathFor(WhisperModelChoice choice) => + Path.Combine(ModelDirectory, $"ggml-{WhisperModelInfo.GgmlTypeFor(choice).ToString().ToLowerInvariant()}.bin"); + + private static string VadModelPath => Path.Combine(ModelDirectory, "ggml-silero-vad-v5.bin"); + + /// + /// Returns true when the given path points to a file with a recognized audio (or A/V) extension. + /// + public static bool IsAudioFile(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return false; + + return AudioExtensions.Contains(Path.GetExtension(path)); + } + + /// + /// Whisper runs on the CPU on every supported Windows build (x64 / arm64, packaged or not), so + /// audio transcription is always available. The model is fetched on first use. + /// + public static bool IsAudioTranscriptionSupported() => true; + + /// True once the selected Whisper model has been downloaded and is available locally. + public static bool IsModelDownloaded() => File.Exists(ModelPathFor(CurrentModelChoice)); + + /// + /// Downloads a GGML model to LocalAppData if it isn't already present, returning its path. The + /// download is written to a temp file first, then moved into place so a cancelled or failed + /// download never leaves a corrupt model behind. + /// + private static async Task EnsureModelDownloadedAsync(WhisperModelChoice choice, IProgress? progress, CancellationToken cancellationToken) + { + string modelPath = ModelPathFor(choice); + if (File.Exists(modelPath)) + return modelPath; + + Directory.CreateDirectory(ModelDirectory); + GgmlType ggmlType = WhisperModelInfo.GgmlTypeFor(choice); + AudioDebugLog.Write($"EnsureModelDownloadedAsync: downloading Whisper '{ggmlType}' model to {modelPath}"); + progress?.Report($"Downloading speech model ({WhisperModelInfo.DisplayName(choice)}, first run)…"); + + string tempPath = modelPath + ".download"; + try + { + using (Stream modelStream = await WhisperGgmlDownloader.Default.GetGgmlModelAsync(ggmlType).ConfigureAwait(false)) + using (FileStream fileWriter = File.Create(tempPath)) + await modelStream.CopyToAsync(fileWriter, cancellationToken).ConfigureAwait(false); + + if (File.Exists(modelPath)) + File.Delete(modelPath); + File.Move(tempPath, modelPath); + AudioDebugLog.Write("EnsureModelDownloadedAsync: download complete"); + return modelPath; + } + catch + { + try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } + throw; + } + } + + /// + /// Returns the shared, cached for the currently selected model, + /// downloading the model if needed. The factory is expensive to create (it loads the model), so + /// it is created once and reused. If the model choice changes, the old factory is disposed and a + /// new one is loaded. + /// + internal static async Task GetFactoryAsync(IProgress? progress, CancellationToken cancellationToken) + { + WhisperModelChoice choice = CurrentModelChoice; + if (_whisperFactory is not null && _loadedModelChoice == choice) + return _whisperFactory; + + await _factoryLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_whisperFactory is not null && _loadedModelChoice == choice) + return _whisperFactory; + + if (_whisperFactory is not null) + { + AudioDebugLog.Write($"GetFactoryAsync: model changed {_loadedModelChoice} -> {choice}, reloading"); + try { _whisperFactory.Dispose(); } catch { } + _whisperFactory = null; + } + + string modelPath = await EnsureModelDownloadedAsync(choice, progress, cancellationToken).ConfigureAwait(false); + AudioDebugLog.Write($"GetFactoryAsync: loading WhisperFactory for {choice} ({WhisperModelInfo.GgmlTypeFor(choice)})"); + _whisperFactory = WhisperFactory.FromPath(modelPath); + _loadedModelChoice = choice; + AudioDebugLog.Write("GetFactoryAsync: WhisperFactory ready"); + return _whisperFactory; + } + finally + { + _factoryLock.Release(); + } + } + + /// + /// Downloads the Silero VAD model to LocalAppData if needed (same temp-then-move pattern), then + /// returns the shared, cached . The VAD model is tiny (~a few MB). + /// + internal static async Task GetVadFactoryAsync(IProgress? progress, CancellationToken cancellationToken) + { + if (_vadFactory is not null) + return _vadFactory; + + await _vadFactoryLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_vadFactory is not null) + return _vadFactory; + + if (!File.Exists(VadModelPath)) + { + Directory.CreateDirectory(ModelDirectory); + AudioDebugLog.Write("GetVadFactoryAsync: downloading Silero VAD model"); + progress?.Report("Downloading voice-activity model…"); + + string tempPath = VadModelPath + ".download"; + try + { + using (Stream vadStream = await WhisperGgmlDownloader.Default.GetGgmlSileroVadModelAsync(SileroVadType.V5_1_2, cancellationToken).ConfigureAwait(false)) + using (FileStream fileWriter = File.Create(tempPath)) + await vadStream.CopyToAsync(fileWriter, cancellationToken).ConfigureAwait(false); + + if (File.Exists(VadModelPath)) + File.Delete(VadModelPath); + File.Move(tempPath, VadModelPath); + } + catch + { + try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } + throw; + } + } + + _vadFactory = WhisperVadFactory.FromPath(VadModelPath); + AudioDebugLog.Write("GetVadFactoryAsync: WhisperVadFactory ready"); + return _vadFactory; + } + finally + { + _vadFactoryLock.Release(); + } + } + + /// + /// Transcribes a complete audio file on-device with Whisper and returns the recognized text. + /// Whisper.net's ProcessAsync yields incrementally as whisper.cpp + /// finishes each segment of the audio, so receives each + /// segment's text as it becomes available (giving "text as it comes in" for long files). The full + /// transcript is still returned. Cancellation stops after the current segment; every segment + /// already surfaced via is preserved by the caller. + /// + public static async Task TranscribeAudioFileAsync(string audioFilePath, IProgress? statusProgress = null, IProgress? segmentProgress = null, CancellationToken cancellationToken = default) + { + AudioDebugLog.Write($"TranscribeAudioFileAsync: START path='{audioFilePath}'"); + + if (!File.Exists(audioFilePath)) + throw new FileNotFoundException("Audio file not found.", audioFilePath); + + long fileSizeKb = new FileInfo(audioFilePath).Length / 1024; + AudioDebugLog.Write($"TranscribeAudioFileAsync: file exists, size={fileSizeKb} KB, ext={Path.GetExtension(audioFilePath)}"); + + // Whisper + audio decoding are CPU-bound; run off the UI thread. + return await Task.Run(async () => + { + WhisperFactory factory = await GetFactoryAsync(statusProgress, cancellationToken).ConfigureAwait(false); + + statusProgress?.Report("Transcribing audio…"); + AudioDebugLog.Write("TranscribeAudioFileAsync: decoding audio to 16 kHz mono WAV"); + using MemoryStream wavStream = DecodeToWav16kMono(audioFilePath); + AudioDebugLog.Write($"TranscribeAudioFileAsync: decoded WAV bytes={wavStream.Length}"); + + Stopwatch stopwatch = Stopwatch.StartNew(); + await using WhisperProcessor processor = factory.CreateBuilder() + .WithLanguage(WhisperModelInfo.LanguageFor(CurrentModelChoice)) + .WithThreads(Math.Max(1, Environment.ProcessorCount - 1)) + .Build(); + + StringBuilder builder = new(); + int segmentCount = 0; + await foreach (SegmentData segment in processor.ProcessAsync(wavStream, cancellationToken).ConfigureAwait(false)) + { + builder.Append(segment.Text); + segmentProgress?.Report(segment.Text); + segmentCount++; + } + + stopwatch.Stop(); + string text = CleanTranscript(builder.ToString()); + AudioDebugLog.Write($"TranscribeAudioFileAsync: DONE in {stopwatch.ElapsedMilliseconds} ms, {segmentCount} segments, result length={text.Length}"); + return text; + }, cancellationToken).ConfigureAwait(false); + } + + /// Collapses whisper's leading spaces / stray whitespace into a tidy transcript. + internal static string CleanTranscript(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return string.Empty; + + return raw.Replace("\r\n", "\n").Trim(); + } + + /// + /// Decodes any Media Foundation-supported audio (wav, mp3, m4a, aac, wma, mp4, …) to a 16 kHz + /// mono 16-bit PCM WAV in memory — the format Whisper expects. + /// + internal static MemoryStream DecodeToWav16kMono(string audioFilePath) + { + MediaFoundationApi.Startup(); + + using MediaFoundationReader reader = new(audioFilePath); + WaveFormat targetFormat = new(16000, 16, 1); + + MemoryStream memoryStream = new(); + using (MediaFoundationResampler resampler = new(reader, targetFormat) { ResamplerQuality = 60 }) + { + // WriteWavFileToStream wraps the stream in an IgnoreDisposeStream, so memoryStream stays open. + WaveFileWriter.WriteWavFileToStream(memoryStream, resampler); + } + + memoryStream.Position = 0; + return memoryStream; + } + + /// Wraps raw 16 kHz mono 16-bit PCM bytes in an in-memory WAV stream for Whisper. + internal static MemoryStream PcmToWav16kMono(byte[] pcm, int count) + { + MemoryStream memoryStream = new(); + using (WaveFileWriter writer = new(new IgnoreDisposeStream(memoryStream), new WaveFormat(16000, 16, 1))) + writer.Write(pcm, 0, count); + + memoryStream.Position = 0; + return memoryStream; + } + + /// + /// Converts a raw captured buffer in an arbitrary (e.g. the + /// 32-bit float stereo mix from WASAPI loopback, or a mic's PCM) to a 16 kHz mono 16-bit WAV + /// stream for Whisper. Uses a fast path when the buffer is already in Whisper's format. + /// + internal static MemoryStream ConvertToWav16kMono(byte[] raw, int count, WaveFormat sourceFormat) + { + if (sourceFormat.Encoding == WaveFormatEncoding.Pcm + && sourceFormat.SampleRate == 16000 + && sourceFormat.Channels == 1 + && sourceFormat.BitsPerSample == 16) + { + return PcmToWav16kMono(raw, count); + } + + MediaFoundationApi.Startup(); + using RawSourceWaveStream rawStream = new(new MemoryStream(raw, 0, count), sourceFormat); + WaveFormat targetFormat = new(16000, 16, 1); + + MemoryStream memoryStream = new(); + using (MediaFoundationResampler resampler = new(rawStream, targetFormat) { ResamplerQuality = 60 }) + WaveFileWriter.WriteWavFileToStream(memoryStream, resampler); + + memoryStream.Position = 0; + return memoryStream; + } + + /// + /// Converts a raw captured buffer in an arbitrary to normalized + /// 16 kHz mono float samples in [-1, 1] — the shape both Silero VAD and Whisper consume directly, + /// avoiding a WAV round-trip. Uses a fast path when the buffer is already 16 kHz mono 16-bit PCM. + /// + internal static float[] ConvertToSamples16kMono(byte[] raw, int count, WaveFormat sourceFormat) + { + if (sourceFormat.Encoding == WaveFormatEncoding.Pcm + && sourceFormat.SampleRate == 16000 + && sourceFormat.Channels == 1 + && sourceFormat.BitsPerSample == 16) + { + int sampleCount = count / 2; + float[] fast = new float[sampleCount]; + for (int i = 0; i < sampleCount; i++) + { + short sample = (short)(raw[i * 2] | (raw[i * 2 + 1] << 8)); + fast[i] = sample / 32768f; + } + return fast; + } + + MediaFoundationApi.Startup(); + using RawSourceWaveStream rawStream = new(new MemoryStream(raw, 0, count), sourceFormat); + WaveFormat targetFormat = new(16000, 16, 1); + using MediaFoundationResampler resampler = new(rawStream, targetFormat) { ResamplerQuality = 60 }; + + List samples = new(count / 4); + byte[] buffer = new byte[16000 * 2]; // ~1 second of 16-bit mono + int read; + while ((read = resampler.Read(buffer, 0, buffer.Length)) > 0) + { + for (int i = 0; i + 1 < read; i += 2) + { + short sample = (short)(buffer[i] | (buffer[i + 1] << 8)); + samples.Add(sample / 32768f); + } + } + return samples.ToArray(); + } +} + +/// The Whisper model a user can pick, trading speed for accuracy / language coverage. +public enum WhisperModelChoice +{ + /// tiny.en — fastest, English only. + TinyEnglish, + + /// base.en — fast, English only. + BaseEnglish, + + /// base — balanced, multilingual with auto language detection (default). + BaseMultilingual, + + /// small — most accurate offered here, multilingual, noticeably slower. + SmallMultilingual, +} + +/// Maps to its GGML model, language, and display name. +internal static class WhisperModelInfo +{ + public static WhisperModelChoice Parse(string? value) => value switch + { + "TinyEnglish" => WhisperModelChoice.TinyEnglish, + "BaseEnglish" => WhisperModelChoice.BaseEnglish, + "SmallMultilingual" => WhisperModelChoice.SmallMultilingual, + _ => WhisperModelChoice.BaseMultilingual, + }; + + public static GgmlType GgmlTypeFor(WhisperModelChoice choice) => choice switch + { + WhisperModelChoice.TinyEnglish => GgmlType.TinyEn, + WhisperModelChoice.BaseEnglish => GgmlType.BaseEn, + WhisperModelChoice.SmallMultilingual => GgmlType.Small, + _ => GgmlType.Base, + }; + + // English-only models can't language-detect, so force English; multilingual models auto-detect. + public static string LanguageFor(WhisperModelChoice choice) => choice switch + { + WhisperModelChoice.TinyEnglish or WhisperModelChoice.BaseEnglish => "en", + _ => "auto", + }; + + public static string DisplayName(WhisperModelChoice choice) => choice switch + { + WhisperModelChoice.TinyEnglish => "Fastest — English", + WhisperModelChoice.BaseEnglish => "Fast — English", + WhisperModelChoice.SmallMultilingual => "Most accurate — multilingual", + _ => "Balanced — multilingual", + }; +} + +/// Where pulls audio from. +public enum LiveCaptureSource +{ + /// The default microphone / recording device. + Microphone, + + /// System output ("what you hear") via WASAPI loopback on the default render device. + SystemAudio, +} + +/// +/// Near-live transcription with Whisper from either the microphone or system output (WASAPI +/// loopback), gated by Silero voice-activity detection. Instead of transcribing fixed time windows +/// (which waste compute on silence and cut words mid-phrase), it buffers audio, runs cheap VAD on a +/// short cadence to find speech regions, and only sends a region to Whisper once it's complete +/// (trailing silence detected). Each completed utterance raises . +/// Events fire on background threads; subscribers must marshal to their UI thread. +/// +public sealed class LiveAudioTranscriber : IDisposable +{ + private const int SampleRate = 16000; + private const int TimerIntervalMs = 500; + private const double MinAudioSeconds = 0.6; // don't bother running VAD on less than this + private const double CompletionSilenceSeconds = 0.4; // trailing silence that marks an utterance done + private const double MaxUtteranceSeconds = 20.0; // hard cap so a long monologue still flushes + + private IWaveIn? _capture; + private WaveFormat? _sourceFormat; + private WhisperFactory? _factory; + private WhisperProcessor? _processor; + private WhisperVadProcessor? _vadProcessor; + private readonly MemoryStream _pcmBuffer = new(); + private readonly object _bufferLock = new(); + private readonly SemaphoreSlim _processingGate = new(1, 1); + private System.Timers.Timer? _chunkTimer; + private volatile bool _isRunning; + private bool _disposed; + + /// Raised with recognized text for each completed (VAD-delimited) utterance. + public event EventHandler? PhraseRecognized; + + public bool IsRunning => _isRunning; + + /// The source the current (or most recent) session is capturing from. + public LiveCaptureSource Source { get; private set; } = LiveCaptureSource.Microphone; + + /// + /// Starts capturing from the requested source (microphone or system loopback) and transcribing + /// VAD-delimited utterances. Returns false when the device isn't available or startup otherwise + /// fails. The Whisper and VAD models are downloaded on first use, so the first call may take a while. + /// + public async Task StartAsync(LiveCaptureSource source = LiveCaptureSource.Microphone) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_isRunning) + return true; + + Source = source; + + try + { + if (source == LiveCaptureSource.Microphone && WaveInEvent.DeviceCount <= 0) + { + AudioDebugLog.Write("LiveAudioTranscriber: no microphone capture device found"); + return false; + } + + WhisperModelChoice choice = AudioTranscriptionUtilities.CurrentModelChoice; + _factory = await AudioTranscriptionUtilities.GetFactoryAsync(null, CancellationToken.None).ConfigureAwait(false); + _processor = _factory.CreateBuilder() + .WithLanguage(WhisperModelInfo.LanguageFor(choice)) + .WithNoContext() // each utterance stands alone: faster and avoids cross-phrase drift + .WithThreads(Math.Max(1, Environment.ProcessorCount - 1)) + .Build(); + + WhisperVadFactory vadFactory = await AudioTranscriptionUtilities.GetVadFactoryAsync(null, CancellationToken.None).ConfigureAwait(false); + _vadProcessor = vadFactory.CreateBuilder() + .WithThreshold(0.5f) + .WithMinSpeechDuration(TimeSpan.FromMilliseconds(250)) + .WithMinSilenceDuration(TimeSpan.FromMilliseconds(300)) + .WithSpeechPadding(TimeSpan.FromMilliseconds(64)) + .WithThreads(Math.Max(1, Environment.ProcessorCount - 1)) + .Build(); + + // Microphone can be forced to 16 kHz mono (WinMM converts); loopback yields the render + // device's mix format (usually 32-bit float stereo) which we resample when processing. + _capture = source == LiveCaptureSource.SystemAudio + ? new WasapiLoopbackCapture() + : new WaveInEvent { WaveFormat = new WaveFormat(16000, 16, 1), BufferMilliseconds = 100 }; + + _sourceFormat = _capture.WaveFormat; + AudioDebugLog.Write($"LiveAudioTranscriber: source={source} model={choice} format={_sourceFormat.Encoding} {_sourceFormat.SampleRate}Hz {_sourceFormat.Channels}ch {_sourceFormat.BitsPerSample}bit"); + + _capture.DataAvailable += Capture_DataAvailable; + _capture.StartRecording(); + + _chunkTimer = new System.Timers.Timer(TimerIntervalMs) { AutoReset = true }; + _chunkTimer.Elapsed += async (_, _) => await ProcessBufferedChunkAsync().ConfigureAwait(false); + _chunkTimer.Start(); + + _isRunning = true; + AudioDebugLog.Write("LiveAudioTranscriber: started"); + return true; + } + catch (Exception ex) + { + AudioDebugLog.Write($"LiveAudioTranscriber: failed to start ({source}): {ex.Message}"); + Cleanup(); + return false; + } + } + + /// + /// Stops capturing, flushes and transcribes any remaining buffered speech, then releases + /// resources. Awaitable so a restart (source/model change) can wait for a clean teardown. + /// + public async Task StopAsync() + { + if (!_isRunning && _capture is null) + return; + + _isRunning = false; + _chunkTimer?.Stop(); + + try { _capture?.StopRecording(); } catch { } + + // Flush whatever remains (this waits for any in-flight pass), then clean up. + try { await ProcessBufferedChunkAsync(flush: true).ConfigureAwait(false); } catch { } + + Cleanup(); + AudioDebugLog.Write("LiveAudioTranscriber: stopped"); + } + + /// Fire-and-forget stop for callers that can't await (see ). + public void Stop() => _ = StopAsync(); + + private void Capture_DataAvailable(object? sender, WaveInEventArgs e) + { + lock (_bufferLock) + _pcmBuffer.Write(e.Buffer, 0, e.BytesRecorded); + } + + /// + /// Runs VAD over the buffered audio and transcribes any completed speech regions. Normal (timed) + /// passes skip if one is already running; a pass waits its turn and + /// forces transcription of whatever speech remains. + /// + private async Task ProcessBufferedChunkAsync(bool flush = false) + { + if (flush) + await _processingGate.WaitAsync().ConfigureAwait(false); + else if (!await _processingGate.WaitAsync(0).ConfigureAwait(false)) + return; + + try + { + WaveFormat? sourceFormat = _sourceFormat; + WhisperProcessor? processor = _processor; + WhisperVadProcessor? vad = _vadProcessor; + if (sourceFormat is null || processor is null || vad is null) + return; + + // Snapshot without clearing — audio keeps arriving while we work; we trim precisely later. + byte[] raw; + lock (_bufferLock) + { + if (_pcmBuffer.Length == 0) + return; + raw = _pcmBuffer.ToArray(); + } + + float[] samples = AudioTranscriptionUtilities.ConvertToSamples16kMono(raw, raw.Length, sourceFormat); + double totalSeconds = samples.Length / (double)SampleRate; + if (!flush && totalSeconds < MinAudioSeconds) + return; + + IReadOnlyList speech = await vad.DetectSpeechAsync(samples).ConfigureAwait(false); + if (speech.Count == 0) + { + // Only silence so far: keep just the tail so the buffer doesn't grow during quiet. + TrimBufferFront(totalSeconds - 0.5, sourceFormat); + return; + } + + bool forced = flush || totalSeconds >= MaxUtteranceSeconds; + double cutSeconds = 0; + StringBuilder phrase = new(); + + for (int i = 0; i < speech.Count; i++) + { + VadSegmentData seg = speech[i]; + bool complete = forced || (totalSeconds - seg.End.TotalSeconds) >= CompletionSilenceSeconds; + if (!complete) + break; // speech still in progress: leave this and later regions buffered + + int start = Math.Max(0, (int)(seg.Start.TotalSeconds * SampleRate) - SampleRate / 20); + int end = Math.Min(samples.Length, (int)(seg.End.TotalSeconds * SampleRate) + SampleRate / 20); + cutSeconds = seg.End.TotalSeconds; + if (end <= start) + continue; + + ReadOnlyMemory slice = new(samples, start, end - start); + StringBuilder segmentText = new(); + await foreach (SegmentData s in processor.ProcessAsync(slice, CancellationToken.None).ConfigureAwait(false)) + segmentText.Append(s.Text); + + string cleaned = AudioTranscriptionUtilities.CleanTranscript(segmentText.ToString()); + if (cleaned.Length > 0) + { + if (phrase.Length > 0) + phrase.Append(' '); + phrase.Append(cleaned); + } + } + + if (cutSeconds > 0) + TrimBufferFront(cutSeconds, sourceFormat); + + if (phrase.Length > 0) + PhraseRecognized?.Invoke(this, phrase.ToString()); + } + catch (Exception ex) + { + AudioDebugLog.Write($"LiveAudioTranscriber: chunk processing error: {ex.Message}"); + } + finally + { + _processingGate.Release(); + } + } + + /// + /// Drops the first of buffered audio (rounded to a whole sample frame). + /// Only the consumed prefix is removed, so audio captured during processing is preserved. + /// + private void TrimBufferFront(double seconds, WaveFormat sourceFormat) + { + if (seconds <= 0) + return; + + int bytesToRemove = (int)(seconds * sourceFormat.AverageBytesPerSecond); + int blockAlign = sourceFormat.BlockAlign; + if (blockAlign > 0) + bytesToRemove -= bytesToRemove % blockAlign; + if (bytesToRemove <= 0) + return; + + lock (_bufferLock) + { + byte[] current = _pcmBuffer.ToArray(); + int remove = Math.Min(bytesToRemove, current.Length); + _pcmBuffer.SetLength(0); + if (current.Length > remove) + _pcmBuffer.Write(current, remove, current.Length - remove); + } + } + + private void Cleanup() + { + if (_chunkTimer is not null) + { + _chunkTimer.Stop(); + _chunkTimer.Dispose(); + _chunkTimer = null; + } + + if (_capture is not null) + { + _capture.DataAvailable -= Capture_DataAvailable; + try { _capture.Dispose(); } catch { } + _capture = null; + } + + _sourceFormat = null; + + if (_processor is not null) + { + try { _processor.Dispose(); } catch { } + _processor = null; + } + + if (_vadProcessor is not null) + { + try { _vadProcessor.Dispose(); } catch { } + _vadProcessor = null; + } + + // _factory / VAD factory are shared and owned by AudioTranscriptionUtilities; drop references only. + _factory = null; + + lock (_bufferLock) + _pcmBuffer.SetLength(0); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + Stop(); + } +} From f2e61054e36871368bccc342ef50bad4f9633b8e Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Thu, 9 Jul 2026 22:05:55 -0500 Subject: [PATCH 04/47] Wire audio transcription into EditTextWindow Route audio files (CLI arg, File > Open, drag/drop) to on-device transcription, streaming each Whisper segment into the editor via a non-blocking, cancellable status bar that preserves already-transcribed text. Add a bottom-bar live-transcription toggle with a right-click menu to pick the capture source (microphone or system audio) and the transcription model, both restarting an active session on change. Co-Authored-By: Claude Opus 4.8 --- Text-Grab/Views/EditTextWindow.xaml | 111 ++++++- Text-Grab/Views/EditTextWindow.xaml.cs | 397 ++++++++++++++++++++++++- 2 files changed, 487 insertions(+), 21 deletions(-) diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 16dab7ab..1d1ff028 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -322,12 +322,8 @@ x:Name="ApplyGrabTemplatePerLineMenuItem" Header="Apply Grab Template Per Line" Visibility="Collapsed" /> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Text-Grab/Views/OpenMediaWindow.xaml.cs b/Text-Grab/Views/OpenMediaWindow.xaml.cs new file mode 100644 index 00000000..b20ff828 --- /dev/null +++ b/Text-Grab/Views/OpenMediaWindow.xaml.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using Text_Grab.Utilities; +using Wpf.Ui.Controls; + +namespace Text_Grab.Views; + +public partial class OpenMediaWindow : FluentWindow +{ + private string? selectedFilePath; + + public OpenMediaWindow() + { + InitializeComponent(); + App.SetTheme(); + } + + private void BrowseButton_Click(object sender, RoutedEventArgs e) + { + Microsoft.Win32.OpenFileDialog dlg = new() + { + Filter = AudioTranscriptionUtilities.GetAudioFileFilter(), + DefaultDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) + }; + + bool? result = dlg.ShowDialog(); + if (result is true) + UpdateFileInfo(dlg.FileName); + } + + private void UpdateFileInfo(string path) + { + FilePathTextBox.Text = path; + FileErrorText.Visibility = Visibility.Collapsed; + FileInfoPanel.Visibility = Visibility.Collapsed; + selectedFilePath = null; + StartTranscriptionButton.IsEnabled = false; + + try + { + AudioTranscriptionUtilities.AudioFileInfo info = AudioTranscriptionUtilities.GetAudioFileInfo(path); + + FileNameText.Text = info.FileName; + FileSizeText.Text = $"Size: {info.FileSizeBytes / (1024.0 * 1024.0):0.#} MB"; + FileDurationText.Text = $"Duration: {info.Duration:mm\\:ss}"; + FileModelText.Text = $"Model: {WhisperModelInfo.DisplayName(AudioTranscriptionUtilities.CurrentModelChoice)}"; + FileInfoPanel.Visibility = Visibility.Visible; + + selectedFilePath = path; + StartTranscriptionButton.IsEnabled = true; + } + catch (Exception ex) + { + FileErrorText.Text = $"⚠ Couldn't read this file: {ex.Message}"; + FileErrorText.Visibility = Visibility.Visible; + } + } + + private void HotWordsLookupButton_Click(object sender, RoutedEventArgs e) + { + QuickSimpleLookup qsl = new() + { + DestinationTextBox = HotWordsTextBox, + IsPickerMode = true, + }; + qsl.Owner = this; + qsl.Show(); + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + Close(); + } + + private async void StartTranscriptionButton_Click(object sender, RoutedEventArgs e) + { + if (Owner is EditTextWindow etw && selectedFilePath is not null) + { + etw.Activate(); + await etw.TranscribeAudioFilesAsync([selectedFilePath], HotWordsTextBox.Text.Trim()); + } + + Close(); + } +} From 141da31230c23943281e6f96e657bc62992b32e1 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 23 Aug 2026 11:49:04 -0500 Subject: [PATCH 26/47] Fix QSL CSV parsing dropping commas in long values LookupItem.ToCSVString writes rows as unquoted "ShortValue,LongValue", so a LongValue containing commas (e.g. a saved hot-words batch) split into more than two cells on reload. The parser rejoined those cells with a space, silently losing the commas. Rejoin with the same delimiter that was used to split when reading a CSV row, so the original value round-trips exactly; tab-delimited rows (typed entry, clipboard paste) keep their existing space-joined behavior. Co-Authored-By: Claude Sonnet 5 --- Text-Grab/Views/QuickSimpleLookup.xaml.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Views/QuickSimpleLookup.xaml.cs b/Text-Grab/Views/QuickSimpleLookup.xaml.cs index 4c9d77f5..63b903f3 100644 --- a/Text-Grab/Views/QuickSimpleLookup.xaml.cs +++ b/Text-Grab/Views/QuickSimpleLookup.xaml.cs @@ -96,9 +96,15 @@ private static LookupItem ParseStringToLookupItem(char splitChar, string row) if (cells.FirstOrDefault() is string firstCell) newRow.ShortValue = firstCell; + // CSV rows are written as "ShortValue,LongValue" with no quoting/escaping (see + // LookupItem.ToCSVString), so a LongValue that itself contains commas splits into more than + // two cells here. Rejoin with the same delimiter to reconstitute the original value instead of + // losing the commas (space-joining is still correct for tab-split rows: typed/pasted multi-cell + // entries are meant to read as one space-separated phrase, not regain literal tab characters). + string joinSeparator = splitChar == ',' ? "," : " "; newRow.LongValue = ""; if (cells.Count > 1 && cells[1] is not null) - newRow.LongValue = string.Join(" ", cells.Skip(1).ToArray()); + newRow.LongValue = string.Join(joinSeparator, cells.Skip(1).ToArray()); newRow.Kind = kind; return newRow; From 120c6b5df05c977efd9db0fd910e36bc3f791f05 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 23 Aug 2026 17:46:05 -0500 Subject: [PATCH 27/47] Add toast notification when audio transcription completes Adds a persisted NotifyOnTranscriptionComplete setting (default on) and a toggle in the Open Audio / Video window, so long-running file transcriptions can notify the user via toast when they finish instead of requiring the editor window to stay in focus. --- Text-Grab/App.config | 3 +++ Text-Grab/Properties/Settings.Designer.cs | 12 ++++++++++++ Text-Grab/Properties/Settings.settings | 3 +++ Text-Grab/Utilities/NotificationUtilities.cs | 8 ++++++++ Text-Grab/Views/EditTextWindow.xaml.cs | 7 +++++++ Text-Grab/Views/OpenMediaWindow.xaml | 14 ++++++++++++++ Text-Grab/Views/OpenMediaWindow.xaml.cs | 14 ++++++++++++++ 7 files changed, 61 insertions(+) diff --git a/Text-Grab/App.config b/Text-Grab/App.config index 533b3696..c60f5e37 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -298,6 +298,9 @@ False + + True + diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index 41c768d6..22e7346a 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -1186,5 +1186,17 @@ public bool HdrBorderlessGranted { this["HdrBorderlessGranted"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("True")] + public bool NotifyOnTranscriptionComplete { + get { + return ((bool)(this["NotifyOnTranscriptionComplete"])); + } + set { + this["NotifyOnTranscriptionComplete"] = value; + } + } } } diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index 187282e7..8f341ed4 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -293,5 +293,8 @@ False + + True + diff --git a/Text-Grab/Utilities/NotificationUtilities.cs b/Text-Grab/Utilities/NotificationUtilities.cs index cab4437b..5331eaf5 100644 --- a/Text-Grab/Utilities/NotificationUtilities.cs +++ b/Text-Grab/Utilities/NotificationUtilities.cs @@ -61,4 +61,12 @@ internal static void ShowToast(string copiedText) toast.Show(); } + + internal static void ShowTranscriptionCompleteToast(string fileDescription) + { + new ToastContentBuilder() + .AddText("Text Grab") + .AddText($"Transcription complete: {fileDescription}") + .Show(); + } } diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index c0e8c828..54c8c9da 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -3031,6 +3031,13 @@ internal async Task TranscribeAudioFilesAsync(IList audioFiles, string? CloseButtonText = "OK" }.ShowDialogAsync(); } + else if (!cancelled && DefaultSettings.NotifyOnTranscriptionComplete) + { + string fileDescription = multiple + ? $"{audioFiles.Count} files" + : Path.GetFileName(audioFiles[0]); + NotificationUtilities.ShowTranscriptionCompleteToast(fileDescription); + } } /// diff --git a/Text-Grab/Views/OpenMediaWindow.xaml b/Text-Grab/Views/OpenMediaWindow.xaml index cd633aa1..2ef86a74 100644 --- a/Text-Grab/Views/OpenMediaWindow.xaml +++ b/Text-Grab/Views/OpenMediaWindow.xaml @@ -134,6 +134,20 @@ + + + + + diff --git a/Text-Grab/Views/OpenMediaWindow.xaml.cs b/Text-Grab/Views/OpenMediaWindow.xaml.cs index b20ff828..a52985d0 100644 --- a/Text-Grab/Views/OpenMediaWindow.xaml.cs +++ b/Text-Grab/Views/OpenMediaWindow.xaml.cs @@ -14,6 +14,8 @@ public OpenMediaWindow() { InitializeComponent(); App.SetTheme(); + + NotifyOnCompleteToggle.IsChecked = AppUtilities.TextGrabSettings.NotifyOnTranscriptionComplete; } private void BrowseButton_Click(object sender, RoutedEventArgs e) @@ -73,6 +75,18 @@ private void CancelButton_Click(object sender, RoutedEventArgs e) Close(); } + private void NotifyOnCompleteToggle_Checked(object sender, RoutedEventArgs e) + { + AppUtilities.TextGrabSettings.NotifyOnTranscriptionComplete = true; + AppUtilities.TextGrabSettings.Save(); + } + + private void NotifyOnCompleteToggle_Unchecked(object sender, RoutedEventArgs e) + { + AppUtilities.TextGrabSettings.NotifyOnTranscriptionComplete = false; + AppUtilities.TextGrabSettings.Save(); + } + private async void StartTranscriptionButton_Click(object sender, RoutedEventArgs e) { if (Owner is EditTextWindow etw && selectedFilePath is not null) From f85954f0b812638d629c749765431a4093403269 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 23 Aug 2026 17:46:50 -0500 Subject: [PATCH 28/47] Add option to include timecodes in transcription output Adds a persisted IncludeTimecodesInTranscription setting (default off) and a toggle in the Open Audio / Video window. When enabled, each Whisper segment is prefixed with its start time (e.g. [01:23]) and placed on its own line, using timing data Whisper.net already provides per segment. --- Text-Grab/App.config | 3 +++ Text-Grab/Properties/Settings.Designer.cs | 12 ++++++++++++ Text-Grab/Properties/Settings.settings | 3 +++ .../Utilities/AudioTranscriptionUtilities.cs | 16 +++++++++++++--- Text-Grab/Views/EditTextWindow.xaml.cs | 3 ++- Text-Grab/Views/OpenMediaWindow.xaml | 14 ++++++++++++++ Text-Grab/Views/OpenMediaWindow.xaml.cs | 13 +++++++++++++ 7 files changed, 60 insertions(+), 4 deletions(-) diff --git a/Text-Grab/App.config b/Text-Grab/App.config index c60f5e37..37e55eb6 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -301,6 +301,9 @@ True + + False + diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index 22e7346a..0136c985 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -1198,5 +1198,17 @@ public bool NotifyOnTranscriptionComplete { this["NotifyOnTranscriptionComplete"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool IncludeTimecodesInTranscription { + get { + return ((bool)(this["IncludeTimecodesInTranscription"])); + } + set { + this["IncludeTimecodesInTranscription"] = value; + } + } } } diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index 8f341ed4..08330566 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -296,5 +296,8 @@ True + + False + diff --git a/Text-Grab/Utilities/AudioTranscriptionUtilities.cs b/Text-Grab/Utilities/AudioTranscriptionUtilities.cs index ef4f0408..6699f4b3 100644 --- a/Text-Grab/Utilities/AudioTranscriptionUtilities.cs +++ b/Text-Grab/Utilities/AudioTranscriptionUtilities.cs @@ -266,8 +266,10 @@ internal static async Task GetVadFactoryAsync(IProgress is preserved by the caller. /// , if given, is passed to Whisper as an initial prompt so it's biased /// toward names/jargon it might otherwise mishear; it applies only to this call, nothing persists. + /// When is true, each segment is prefixed with its start time + /// (e.g. [01:23]) and placed on its own line. /// - public static async Task TranscribeAudioFileAsync(string audioFilePath, string? hotWords = null, IProgress? statusProgress = null, IProgress? segmentProgress = null, CancellationToken cancellationToken = default) + public static async Task TranscribeAudioFileAsync(string audioFilePath, string? hotWords = null, IProgress? statusProgress = null, IProgress? segmentProgress = null, CancellationToken cancellationToken = default, bool includeTimecodes = false) { AudioDebugLog.Write($"TranscribeAudioFileAsync: START path='{audioFilePath}'"); @@ -303,8 +305,12 @@ public static async Task TranscribeAudioFileAsync(string audioFilePath, int segmentCount = 0; await foreach (SegmentData segment in processor.ProcessAsync(wavStream, cancellationToken).ConfigureAwait(false)) { - builder.Append(segment.Text); - segmentProgress?.Report(segment.Text); + string segmentText = includeTimecodes + ? $"[{FormatTimecode(segment.Start)}]{segment.Text}{Environment.NewLine}" + : segment.Text; + + builder.Append(segmentText); + segmentProgress?.Report(segmentText); segmentCount++; } @@ -315,6 +321,10 @@ public static async Task TranscribeAudioFileAsync(string audioFilePath, }, cancellationToken).ConfigureAwait(false); } + /// Formats a segment's start time as mm:ss, or h:mm:ss once past an hour. + internal static string FormatTimecode(TimeSpan t) => + t.TotalHours >= 1 ? t.ToString(@"h\:mm\:ss") : t.ToString(@"mm\:ss"); + /// Collapses whisper's leading spaces / stray whitespace into a tidy transcript. internal static string CleanTranscript(string raw) { diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 54c8c9da..c5275d11 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -2989,7 +2989,8 @@ internal async Task TranscribeAudioFilesAsync(IList audioFiles, string? AppendTranscriptionText($"# {Path.GetFileName(audioFile)}{Environment.NewLine}"); string transcription = await AudioTranscriptionUtilities.TranscribeAudioFileAsync( - audioFile, hotWords, statusProgress, segmentProgress, cancellationToken); + audioFile, hotWords, statusProgress, segmentProgress, cancellationToken, + includeTimecodes: DefaultSettings.IncludeTimecodesInTranscription); if (string.IsNullOrWhiteSpace(transcription)) AppendTranscriptionText("(no speech recognized)"); diff --git a/Text-Grab/Views/OpenMediaWindow.xaml b/Text-Grab/Views/OpenMediaWindow.xaml index 2ef86a74..18dd35f8 100644 --- a/Text-Grab/Views/OpenMediaWindow.xaml +++ b/Text-Grab/Views/OpenMediaWindow.xaml @@ -148,6 +148,20 @@ VerticalAlignment="Center" Text="Show a notification when transcription is done" /> + + + + + diff --git a/Text-Grab/Views/OpenMediaWindow.xaml.cs b/Text-Grab/Views/OpenMediaWindow.xaml.cs index a52985d0..27cbf1d0 100644 --- a/Text-Grab/Views/OpenMediaWindow.xaml.cs +++ b/Text-Grab/Views/OpenMediaWindow.xaml.cs @@ -16,6 +16,7 @@ public OpenMediaWindow() App.SetTheme(); NotifyOnCompleteToggle.IsChecked = AppUtilities.TextGrabSettings.NotifyOnTranscriptionComplete; + IncludeTimecodesToggle.IsChecked = AppUtilities.TextGrabSettings.IncludeTimecodesInTranscription; } private void BrowseButton_Click(object sender, RoutedEventArgs e) @@ -87,6 +88,18 @@ private void NotifyOnCompleteToggle_Unchecked(object sender, RoutedEventArgs e) AppUtilities.TextGrabSettings.Save(); } + private void IncludeTimecodesToggle_Checked(object sender, RoutedEventArgs e) + { + AppUtilities.TextGrabSettings.IncludeTimecodesInTranscription = true; + AppUtilities.TextGrabSettings.Save(); + } + + private void IncludeTimecodesToggle_Unchecked(object sender, RoutedEventArgs e) + { + AppUtilities.TextGrabSettings.IncludeTimecodesInTranscription = false; + AppUtilities.TextGrabSettings.Save(); + } + private async void StartTranscriptionButton_Click(object sender, RoutedEventArgs e) { if (Owner is EditTextWindow etw && selectedFilePath is not null) From 610c26df69b8fc003cf49d9bfa93fbd3923f55bc Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 23 Aug 2026 17:47:15 -0500 Subject: [PATCH 29/47] Cache Grab Templates in memory to avoid disk I/O on every menu open GetAllTemplates() previously re-read and re-parsed GrabTemplates.json (and could trigger a slow Settings.Save() to keep the legacy setting and file-backed copy in sync) on every call, including every time a menu that lists templates was opened. This was the main cause of a noticeable delay opening EditTextWindow's "Capture" menu. Templates are now resolved once per process (or since the last write) and cached; GetAllTemplates() returns a fresh list copy each time so structural edits by one caller can't leak into another caller's list before being persisted. Test seams (TestFilePath, TestPreferFileBackedMode) invalidate the cache on assignment so the existing test suite's out-of-band file/settings writes still work. --- Text-Grab/Utilities/GrabTemplateManager.cs | 39 ++++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Text-Grab/Utilities/GrabTemplateManager.cs b/Text-Grab/Utilities/GrabTemplateManager.cs index 500e9f3b..afd3bfd5 100644 --- a/Text-Grab/Utilities/GrabTemplateManager.cs +++ b/Text-Grab/Utilities/GrabTemplateManager.cs @@ -33,12 +33,33 @@ public static class GrabTemplateManager private const string TemplatesFileName = "GrabTemplates.json"; + // In-memory cache of the resolved template list. GetAllTemplates() used to hit disk + // (and potentially Settings.Save(), which is slow) on every call — including every time + // a menu that lists templates (e.g. the EditTextWindow "Capture" menu) was opened. Cached + // here instead and only refreshed by writes that go through this class. + private static List? _cachedTemplates; + // Allow tests to override the file path. // TODO: If more test seams are needed, consider consolidating these into a small // options/config object instead of individual static properties. - internal static string? TestFilePath { get; set; } + private static string? _testFilePath; + internal static string? TestFilePath + { + get => _testFilePath; + set { _testFilePath = value; InvalidateCache(); } + } + internal static string? TestImagesFolderPath { get; set; } - internal static bool? TestPreferFileBackedMode { get; set; } + + private static bool? _testPreferFileBackedMode; + internal static bool? TestPreferFileBackedMode + { + get => _testPreferFileBackedMode; + set { _testPreferFileBackedMode = value; InvalidateCache(); } + } + + /// Drops the in-memory template cache so the next read re-resolves from disk/settings. + internal static void InvalidateCache() => _cachedTemplates = null; private static bool PreferFileBackedTemplates => TestPreferFileBackedMode ?? AppUtilities.TextGrabSettingsService.IsFileBackedManagedSettingsEnabled; @@ -131,8 +152,19 @@ public static string GetTemplateImagesFolder() // ── Read ────────────────────────────────────────────────────────────────── - /// Returns all saved templates, or an empty list if none exist. + /// + /// Returns all saved templates, or an empty list if none exist. Resolved once per process + /// (or since the last write/) and cached; callers get a fresh + /// list instance each time so structural edits (add/remove) by one caller can't leak into + /// another caller's list before being persisted. + /// public static List GetAllTemplates() + { + _cachedTemplates ??= LoadTemplatesFromStorage(); + return [.. _cachedTemplates]; + } + + private static List LoadTemplatesFromStorage() { try { @@ -173,6 +205,7 @@ public static void SaveTemplates(List templates) { string json = JsonSerializer.Serialize(templates, JsonOptions); SaveTemplatesJson(json); + _cachedTemplates = [.. templates]; } internal static string GetTemplatesJsonForExport() From bf89591473cd67c36334edfdb6a40238544604a7 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 23 Aug 2026 17:47:24 -0500 Subject: [PATCH 30/47] Load Grab Template menu items only when that submenu opens LoadGrabTemplateMenuItems() ran on every "Capture" menu open, rebuilding the template list even when the user was heading for an unrelated item. Moved it to GrabTemplateMenuItem's own SubmenuOpened event so it only runs when that specific "Use Grab Template..." submenu is opened. --- Text-Grab/Views/EditTextWindow.xaml | 5 ++++- Text-Grab/Views/EditTextWindow.xaml.cs | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index bf7efaa9..2a475327 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -706,7 +706,10 @@ Click="ReadFolderOfImages_Click" Header="_Extract Text from Images in Folder..." /> - + Date: Sun, 23 Aug 2026 17:47:33 -0500 Subject: [PATCH 31/47] Reuse cached language list for capture menus instead of re-probing GetCaptureLanguagesAsync() had its own copy of the UI-automation / Windows AI / OCR-engine language enumeration logic, duplicating (and never caching) the work LanguageService.GetAllLanguages() already does. The Windows AI readiness checks in particular are real WinRT/WinAppSDK probes that can be slow, especially the first time in a process, so every newly opened EditTextWindow paid that cost again the first time its "Capture" menu was opened. Now builds from the cached LanguageUtilities.GetAllLanguages() and only adds the Tesseract-specific entries on top, spliced in to preserve the original ordering. --- .../Utilities/CaptureLanguageUtilities.cs | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/Text-Grab/Utilities/CaptureLanguageUtilities.cs b/Text-Grab/Utilities/CaptureLanguageUtilities.cs index c5e96756..bed65194 100644 --- a/Text-Grab/Utilities/CaptureLanguageUtilities.cs +++ b/Text-Grab/Utilities/CaptureLanguageUtilities.cs @@ -4,37 +4,33 @@ using System.Threading.Tasks; using Text_Grab.Interfaces; using Text_Grab.Models; -using Windows.Media.Ocr; namespace Text_Grab.Utilities; internal static class CaptureLanguageUtilities { + /// + /// Builds the language list for capture menus. The UI-automation / Windows AI / plain-OCR + /// portion comes from , which caches those + /// checks (each of which can be a genuinely slow WinRT/WinAppSDK probe) instead of redoing + /// them on every call — this used to duplicate that work uncached, which is what made menus + /// like EditTextWindow's "Capture" menu slow to open, especially in new windows. + /// public static async Task> GetCaptureLanguagesAsync(bool includeTesseract) { - List languages = []; - - if (AppUtilities.TextGrabSettings.UiAutomationEnabled) - languages.Add(new UiAutomationLang()); - - if (WindowsAiUtilities.CanDeviceUseWinAI()) - languages.Add(new WindowsAiLang()); - - if (AppUtilities.TextGrabSettings.WindowsAiDescriptionEnabled - && WindowsAiUtilities.CanDeviceDescribeImagesWithWinAI()) - { - languages.Add(new WindowsAiDescriptionLang()); - } + List languages = [.. LanguageUtilities.GetAllLanguages()]; if (includeTesseract && AppUtilities.TextGrabSettings.UseTesseract && TesseractHelper.CanLocateTesseractExe()) { - languages.AddRange(await TesseractHelper.TesseractLanguages()); - } + List tesseractLanguages = await TesseractHelper.TesseractLanguages(); - foreach (Windows.Globalization.Language language in OcrEngine.AvailableRecognizerLanguages) - languages.Add(new GlobalLang(language)); + // Insert before the plain OCR languages (GlobalLang), after the UiAutomation/WindowsAi + // pseudo-languages, to preserve the original ordering. + int insertIndex = languages.FindIndex(l => l is GlobalLang); + languages.InsertRange(insertIndex < 0 ? languages.Count : insertIndex, tesseractLanguages); + } return languages; } From 53c359586c22fb27533a16532342765166f50cf3 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 23 Aug 2026 18:27:38 -0500 Subject: [PATCH 32/47] Add Select-Black.ico and Select-White.ico to project Added two new icon files, Select-Black.ico and Select-White.ico, as content. Updated the .csproj to include these icons for build and packaging, ensuring they are copied to the output directory. Removed any previous references to these icons as needed. --- Text-Grab/Images/Select-Black.ico | Bin 0 -> 360414 bytes Text-Grab/Images/Select-White.ico | Bin 0 -> 360414 bytes Text-Grab/Text-Grab.csproj | 8 ++++++++ 3 files changed, 8 insertions(+) create mode 100644 Text-Grab/Images/Select-Black.ico create mode 100644 Text-Grab/Images/Select-White.ico diff --git a/Text-Grab/Images/Select-Black.ico b/Text-Grab/Images/Select-Black.ico new file mode 100644 index 0000000000000000000000000000000000000000..91007d4dc3bffcffff1ff5aa2bf11f86d3270ca7 GIT binary patch literal 360414 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 XA1=xDd`(36;%Cfs{uIq&;ko}0z9Q_P literal 0 HcmV?d00001 diff --git a/Text-Grab/Images/Select-White.ico b/Text-Grab/Images/Select-White.ico new file mode 100644 index 0000000000000000000000000000000000000000..008710ef6ec5b8b067ee9a596b5048b4fa2b9e4b GIT binary patch literal 360414 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 + + @@ -149,6 +151,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + PreserveNewest From 94f1c5205328a51ac87fa45fcb8ae3552b29ae3d Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 23 Aug 2026 18:30:00 -0500 Subject: [PATCH 33/47] Add user setting TrayIconStyle with default "Color" Added TrayIconStyle to App.config and Settings.settings as a user-scoped setting with default "Color". Generated property in Settings.Designer.cs with proper attributes. No changes to other settings. --- Text-Grab/App.config | 3 +++ Text-Grab/Properties/Settings.Designer.cs | 16 ++++++++++++++-- Text-Grab/Properties/Settings.settings | 5 ++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Text-Grab/App.config b/Text-Grab/App.config index 37e55eb6..19e9e9e3 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -121,6 +121,9 @@ System + + Color + False diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index 0136c985..764c6b8b 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -479,6 +479,18 @@ public string AppTheme { } } + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("Color")] + public string TrayIconStyle { + get { + return ((string)(this["TrayIconStyle"])); + } + set { + this["TrayIconStyle"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("False")] @@ -1186,7 +1198,7 @@ public bool HdrBorderlessGranted { this["HdrBorderlessGranted"] = value; } } - + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("True")] @@ -1198,7 +1210,7 @@ public bool NotifyOnTranscriptionComplete { this["NotifyOnTranscriptionComplete"] = value; } } - + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("False")] diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index 08330566..701b7e8f 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -116,6 +116,9 @@ System + + Color + False @@ -300,4 +303,4 @@ False - + \ No newline at end of file From 06ce22e32c307e1268c4c5ad1b3423f28366a459 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 23 Aug 2026 18:30:22 -0500 Subject: [PATCH 34/47] Support tray icon style refresh and new style enum Added RefreshTrayIconStyle call in App.xaml.cs to update the tray icon when applying the system accent color. Introduced TrayIconStyle enum (Color, Monochrome) in Enums.cs to support multiple tray icon appearance modes. --- Text-Grab/App.xaml.cs | 2 ++ Text-Grab/Enums.cs | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/Text-Grab/App.xaml.cs b/Text-Grab/App.xaml.cs index a1b96440..48407841 100644 --- a/Text-Grab/App.xaml.cs +++ b/Text-Grab/App.xaml.cs @@ -162,6 +162,8 @@ public static void SetTheme(object? sender = null, EventArgs? e = null) // for now this is best but... not ideal ApplicationAccentColorManager.ApplySystemAccent(); + NotifyIconUtilities.RefreshTrayIconStyle(); + // TODO: try to apply the teal color again, maybe something in WPFUI is broken // Color teal = (Color)ColorConverter.ConvertFromString("#308E98"); diff --git a/Text-Grab/Enums.cs b/Text-Grab/Enums.cs index d02308ab..d88a3648 100644 --- a/Text-Grab/Enums.cs +++ b/Text-Grab/Enums.cs @@ -13,6 +13,12 @@ public enum AppTheme Light = 2 } +public enum TrayIconStyle +{ + Color = 0, + Monochrome = 1, +} + public enum CurrentCase { Lower = 0, From 8042e36a1ac9ea06d8753b49eb07e4dcffaf15b9 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 23 Aug 2026 18:30:40 -0500 Subject: [PATCH 35/47] Add user setting for tray icon style (color/monochrome) Users can now choose between color or monochrome tray icons via GeneralSettings. Added ApplyTrayIconStyle() in NotifyIconWindow and RefreshTrayIconStyle() utility for immediate updates. UI and logic updated to persist and apply user preference, with thread-safe icon refresh. --- Text-Grab/Controls/NotifyIconWindow.xaml.cs | 14 +++++++++ Text-Grab/Pages/GeneralSettings.xaml | 20 ++++++++++++ Text-Grab/Pages/GeneralSettings.xaml.cs | 34 +++++++++++++++++++++ Text-Grab/Utilities/NotifyIconUtilities.cs | 14 +++++++++ 4 files changed, 82 insertions(+) diff --git a/Text-Grab/Controls/NotifyIconWindow.xaml.cs b/Text-Grab/Controls/NotifyIconWindow.xaml.cs index 0579877e..85d9294f 100644 --- a/Text-Grab/Controls/NotifyIconWindow.xaml.cs +++ b/Text-Grab/Controls/NotifyIconWindow.xaml.cs @@ -67,6 +67,8 @@ private void Window_Loaded(object sender, RoutedEventArgs e) HideFromAltTab(); NotifyIcon.Visibility = Visibility.Visible; + ApplyTrayIconStyle(); + string toolTipText = "Text Grab"; TextGrabMode defaultLaunchSetting = Enum.Parse(DefaultSettings.DefaultLaunch, true); @@ -91,6 +93,18 @@ private void Window_Loaded(object sender, RoutedEventArgs e) NotifyIcon.TooltipText = toolTipText; } + public void ApplyTrayIconStyle() + { + bool isMonochrome = Enum.TryParse(DefaultSettings.TrayIconStyle, true, out TrayIconStyle style) + && style == TrayIconStyle.Monochrome; + + string iconPath = isMonochrome + ? (SystemThemeUtility.IsLightTheme() ? "/Images/Select-Black.ico" : "/Images/Select-White.ico") + : "/Images/TealSelect40.png"; + + NotifyIcon.Icon = new BitmapImage(new Uri($"pack://application:,,,{iconPath}")); + } + private void EditWindowMenuItem_Click(object sender, RoutedEventArgs e) { EditTextWindow etw = new(); diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml index f470b199..b9f287ac 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -107,6 +107,26 @@ + + + + + + + (DefaultSettings.DefaultLaunch, true); switch (defaultLaunchSetting) { @@ -255,6 +269,26 @@ private void DarkThemeRdBtn_Checked(object sender, RoutedEventArgs e) App.SetTheme(); } + private void ColorTrayIconRdBtn_Checked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + DefaultSettings.TrayIconStyle = TrayIconStyle.Color.ToString(); + DefaultSettings.Save(); + NotifyIconUtilities.RefreshTrayIconStyle(); + } + + private void MonochromeTrayIconRdBtn_Checked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + DefaultSettings.TrayIconStyle = TrayIconStyle.Monochrome.ToString(); + DefaultSettings.Save(); + NotifyIconUtilities.RefreshTrayIconStyle(); + } + private void ReadBarcodesBarcode_Checked(object sender, RoutedEventArgs e) { if (!settingsSet) diff --git a/Text-Grab/Utilities/NotifyIconUtilities.cs b/Text-Grab/Utilities/NotifyIconUtilities.cs index 9bc10ba8..eadf9cac 100644 --- a/Text-Grab/Utilities/NotifyIconUtilities.cs +++ b/Text-Grab/Utilities/NotifyIconUtilities.cs @@ -232,6 +232,20 @@ private static NotifyIconWindow CreateNotifyIconWindow() return notifyIconWindow; } + public static void RefreshTrayIconStyle() + { + // Windows theme changes are observed via a registry watcher that raises its event on a + // background thread, but NotifyIcon.Icon is a DependencyProperty owned by the UI thread. + System.Windows.Threading.Dispatcher dispatcher = Application.Current.Dispatcher; + if (!dispatcher.CheckAccess()) + { + dispatcher.BeginInvoke(RefreshTrayIconStyle); + return; + } + + GetExistingNotifyIconWindow()?.ApplyTrayIconStyle(); + } + private static NotifyIconWindow? GetExistingNotifyIconWindow() { return Application.Current.Windows.OfType().FirstOrDefault(); From 135f770b1888c7ee7a3b035ec3f6771b863682b8 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 23 Aug 2026 18:40:09 -0500 Subject: [PATCH 36/47] Add "Just Icon" option to transcribe button context menu Lets users hide the text label on the live transcription bottom bar button, keeping only the mic/speaker icon. Co-Authored-By: Claude Sonnet 5 --- Text-Grab/App.config | 3 +++ Text-Grab/Properties/Settings.Designer.cs | 14 +++++++++++++- Text-Grab/Properties/Settings.settings | 3 +++ Text-Grab/Views/EditTextWindow.xaml | 6 ++++++ Text-Grab/Views/EditTextWindow.xaml.cs | 10 ++++++++++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Text-Grab/App.config b/Text-Grab/App.config index 19e9e9e3..9b6a6b58 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -199,6 +199,9 @@ True + + False + True diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index 764c6b8b..c9967880 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -790,7 +790,19 @@ public bool EtwShowTranscribe { this["EtwShowTranscribe"] = value; } } - + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool TranscribeButtonJustIcon { + get { + return ((bool)(this["TranscribeButtonJustIcon"])); + } + set { + this["TranscribeButtonJustIcon"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("True")] diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index 701b7e8f..d4ece6f8 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -194,6 +194,9 @@ True + + False + True diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 2a475327..6f1077f3 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -1323,6 +1323,12 @@ IsCheckable="True" Tag="SmallMultilingual" /> + + diff --git a/Text-Grab/Views/EditTextWindow.xaml.cs b/Text-Grab/Views/EditTextWindow.xaml.cs index 788ee443..468f3acd 100644 --- a/Text-Grab/Views/EditTextWindow.xaml.cs +++ b/Text-Grab/Views/EditTextWindow.xaml.cs @@ -6148,6 +6148,8 @@ private void Window_Loaded(object sender, RoutedEventArgs e) TranscriptionOptionsMenuItem.Visibility = Visibility.Visible; OpenAudioVideoMenuItem.Visibility = Visibility.Visible; SyncTranscriptionModelMenu(); + TranscribeJustIconMenuItem.IsChecked = DefaultSettings.TranscribeButtonJustIcon; + LiveTranscriptionLabel.Visibility = DefaultSettings.TranscribeButtonJustIcon ? Visibility.Collapsed : Visibility.Visible; } // Initialize selectedILanguage with the last used OCR language from settings @@ -7107,6 +7109,14 @@ private void TranscriptionModelMenuItem_Click(object sender, RoutedEventArgs e) } } + private void TranscribeJustIconMenuItem_Click(object sender, RoutedEventArgs e) + { + bool justIcon = TranscribeJustIconMenuItem.IsChecked; + DefaultSettings.TranscribeButtonJustIcon = justIcon; + DefaultSettings.Save(); + LiveTranscriptionLabel.Visibility = justIcon ? Visibility.Collapsed : Visibility.Visible; + } + /// Reflects the persisted transcription-model choice in the context-menu check marks. private void SyncTranscriptionModelMenu() { From 6b995a428351939ad06eb8dc7d392a79cacda267 Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Sun, 23 Aug 2026 20:13:16 -0500 Subject: [PATCH 37/47] Add determinate progress bars for audio transcription Added real-time progress bars to EditTextWindow and OpenMediaWindow for audio transcription, reflecting actual progress through the audio clip. Updated AudioTranscriptionUtilities to support progress reporting via IProgress. Improved Cancel button logic to handle in-progress transcriptions and updated UI state management for better user feedback. Status text now shows percentage complete during transcription. --- .../Utilities/AudioTranscriptionUtilities.cs | 13 ++++- Text-Grab/Views/EditTextWindow.xaml | 20 +++++-- Text-Grab/Views/EditTextWindow.xaml.cs | 28 +++++++-- Text-Grab/Views/OpenMediaWindow.xaml | 57 ++++++++++++++----- Text-Grab/Views/OpenMediaWindow.xaml.cs | 42 +++++++++++++- 5 files changed, 135 insertions(+), 25 deletions(-) diff --git a/Text-Grab/Utilities/AudioTranscriptionUtilities.cs b/Text-Grab/Utilities/AudioTranscriptionUtilities.cs index 6699f4b3..76f1803d 100644 --- a/Text-Grab/Utilities/AudioTranscriptionUtilities.cs +++ b/Text-Grab/Utilities/AudioTranscriptionUtilities.cs @@ -268,8 +268,11 @@ internal static async Task GetVadFactoryAsync(IProgress is true, each segment is prefixed with its start time /// (e.g. [01:23]) and placed on its own line. + /// , if given, reports how far playback has reached through the + /// clip (0.0-1.0) after each segment, based on that segment's end time versus the clip's total + /// duration — lets callers show a real progress bar instead of an indeterminate spinner. /// - public static async Task TranscribeAudioFileAsync(string audioFilePath, string? hotWords = null, IProgress? statusProgress = null, IProgress? segmentProgress = null, CancellationToken cancellationToken = default, bool includeTimecodes = false) + public static async Task TranscribeAudioFileAsync(string audioFilePath, string? hotWords = null, IProgress? statusProgress = null, IProgress? segmentProgress = null, CancellationToken cancellationToken = default, bool includeTimecodes = false, IProgress? clipProgress = null) { AudioDebugLog.Write($"TranscribeAudioFileAsync: START path='{audioFilePath}'"); @@ -289,6 +292,9 @@ public static async Task TranscribeAudioFileAsync(string audioFilePath, using MemoryStream wavStream = DecodeToWav16kMono(audioFilePath); AudioDebugLog.Write($"TranscribeAudioFileAsync: decoded WAV bytes={wavStream.Length}"); + // 16 kHz mono 16-bit PCM, 44-byte WAV header: 32,000 bytes/second of audio. + double clipTotalSeconds = Math.Max(0, wavStream.Length - 44) / 32000.0; + Stopwatch stopwatch = Stopwatch.StartNew(); WhisperProcessorBuilder processorBuilder = factory.CreateBuilder() .WithLanguage(WhisperModelInfo.LanguageFor(CurrentModelChoice)) @@ -312,8 +318,13 @@ public static async Task TranscribeAudioFileAsync(string audioFilePath, builder.Append(segmentText); segmentProgress?.Report(segmentText); segmentCount++; + + if (clipTotalSeconds > 0) + clipProgress?.Report(Math.Clamp(segment.End.TotalSeconds / clipTotalSeconds, 0.0, 1.0)); } + clipProgress?.Report(1.0); + stopwatch.Stop(); string text = CleanTranscript(builder.ToString()); AudioDebugLog.Write($"TranscribeAudioFileAsync: DONE in {stopwatch.ElapsedMilliseconds} ms, {segmentCount} segments, result length={text.Length}"); diff --git a/Text-Grab/Views/EditTextWindow.xaml b/Text-Grab/Views/EditTextWindow.xaml index 6f1077f3..0bee15ab 100644 --- a/Text-Grab/Views/EditTextWindow.xaml +++ b/Text-Grab/Views/EditTextWindow.xaml @@ -1510,11 +1510,21 @@ Width="18" Height="18" IsIndeterminate="True" /> - + + + + +