From eaa31286ee5f217f0a65a9b9900b4505a8768da0 Mon Sep 17 00:00:00 2001 From: Kirsty McNaught Date: Thu, 21 May 2026 08:18:28 +0100 Subject: [PATCH 1/3] feat: add Text-to-Speech post-grab action Adds a "Speak text" PostGrabAction that reads OCR output aloud using Windows.Media.SpeechSynthesis. Speaks the final transformed text after all other actions run in FullscreenGrab; in GrabFrame, speaks only when the captured text changes to avoid repeating on every OCR tick. - ITtsEngine interface for future engine swappability - WindowsSpeechEngine wraps WinRT SpeechSynthesizer + MediaPlayer - TtsService queues utterances via Channel so new text waits rather than interrupting in-progress speech - TtsSpeakWordLimit setting (default 100) truncates long captures; configurable in General Settings - PostGrabActionManager: new SpeakText_Click action at order 6.6 - GrabFrame: speaks on text change when action is checked - Tests: count updated to 6, "Speak text" assertion, fire-and-forget test Co-Authored-By: Claude Sonnet 4.6 --- Tests/PostGrabActionManagerTests.cs | 18 +++++- Text-Grab/Interfaces/ITtsEngine.cs | 9 +++ Text-Grab/Pages/GeneralSettings.xaml | 32 ++++++++++ Text-Grab/Pages/GeneralSettings.xaml.cs | 23 +++++++ Text-Grab/Properties/Settings.Designer.cs | 12 ++++ Text-Grab/Properties/Settings.settings | 3 + Text-Grab/Services/TtsService.cs | 65 ++++++++++++++++++++ Text-Grab/Services/WindowsSpeechEngine.cs | 37 +++++++++++ Text-Grab/Utilities/PostGrabActionManager.cs | 16 ++++- Text-Grab/Views/GrabFrame.xaml.cs | 13 ++++ 10 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 Text-Grab/Interfaces/ITtsEngine.cs create mode 100644 Text-Grab/Services/TtsService.cs create mode 100644 Text-Grab/Services/WindowsSpeechEngine.cs diff --git a/Tests/PostGrabActionManagerTests.cs b/Tests/PostGrabActionManagerTests.cs index 8e47ca19..7a0a3b92 100644 --- a/Tests/PostGrabActionManagerTests.cs +++ b/Tests/PostGrabActionManagerTests.cs @@ -13,7 +13,7 @@ public void GetDefaultPostGrabActions_ReturnsExpectedCount() // Assert Assert.NotNull(actions); - Assert.Equal(5, actions.Count); + Assert.Equal(6, actions.Count); } [Fact] @@ -28,6 +28,7 @@ public void GetDefaultPostGrabActions_ContainsExpectedActions() Assert.Contains(actions, a => a.ButtonText == "Remove duplicate lines"); Assert.Contains(actions, a => a.ButtonText == "Web Search"); Assert.Contains(actions, a => a.ButtonText == "Try to insert text"); + Assert.Contains(actions, a => a.ButtonText == "Speak text"); //Assert.Contains(actions, a => a.ButtonText == "Translate to system language"); } @@ -103,6 +104,21 @@ public async System.Threading.Tasks.Task ExecutePostGrabAction_RemoveDuplicateLi Assert.Single(lines, l => l == "Line 1"); } + [Fact] + public async Task ExecutePostGrabAction_SpeakText_ReturnsTextUnchanged() + { + // Arrange + ButtonInfo action = PostGrabActionManager.GetDefaultPostGrabActions() + .First(a => a.ClickEvent == "SpeakText_Click"); + string input = "Hello world"; + + // Act + string result = await PostGrabActionManager.ExecutePostGrabAction(action, input); + + // Assert — TTS is fire-and-forget; text must pass through unchanged + Assert.Equal(input, result); + } + [Fact] public void GetCheckState_DefaultOff_ReturnsFalse() { diff --git a/Text-Grab/Interfaces/ITtsEngine.cs b/Text-Grab/Interfaces/ITtsEngine.cs new file mode 100644 index 00000000..2ab4f43f --- /dev/null +++ b/Text-Grab/Interfaces/ITtsEngine.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Text_Grab.Interfaces; + +public interface ITtsEngine +{ + Task SpeakAsync(string text, CancellationToken ct); +} diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml index 75b2bdab..d1d07341 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -387,5 +387,37 @@ Keep recent history of Grabs and Edit Text Windows + + + + + + + + diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index 987e789f..168b0401 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -139,6 +139,7 @@ private async void Page_Loaded(object sender, RoutedEventArgs e) TryInsertCheckbox.IsChecked = DefaultSettings.TryInsert; InsertDelaySeconds = DefaultSettings.InsertDelay; SecondsTextBox.Text = InsertDelaySeconds.ToString("##.#", System.Globalization.CultureInfo.InvariantCulture); + TtsSpeakWordLimitTextBox.Text = DefaultSettings.TtsSpeakWordLimit.ToString(); // Context menu integration - only available for unpackaged apps if (!AppUtilities.IsPackaged()) @@ -181,6 +182,28 @@ private void ValidateTextIsNumber(object sender, TextChangedEventArgs e) } } + private void TtsSpeakWordLimitTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + if (!settingsSet) + return; + + if (sender is System.Windows.Controls.TextBox textBox) + { + bool wasAbleToConvert = int.TryParse(textBox.Text, out int parsedValue); + if (wasAbleToConvert && parsedValue > 0) + { + DefaultSettings.TtsSpeakWordLimit = parsedValue; + TtsWordLimitError.Visibility = Visibility.Collapsed; + textBox.BorderBrush = GoodBrush; + } + else + { + TtsWordLimitError.Visibility = Visibility.Visible; + textBox.BorderBrush = BadBrush; + } + } + } + private void FullScreenRDBTN_Checked(object sender, RoutedEventArgs e) { if (!settingsSet) diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index 073f399f..01b42633 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -958,5 +958,17 @@ public bool RegisterOpenWith { this["RegisterOpenWith"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("100")] + public int TtsSpeakWordLimit { + get { + return ((int)(this["TtsSpeakWordLimit"])); + } + set { + this["TtsSpeakWordLimit"] = value; + } + } } } diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index be4eb667..86766019 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -236,5 +236,8 @@ False + + 100 + diff --git a/Text-Grab/Services/TtsService.cs b/Text-Grab/Services/TtsService.cs new file mode 100644 index 00000000..aa642854 --- /dev/null +++ b/Text-Grab/Services/TtsService.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Text_Grab.Interfaces; +using Text_Grab.Properties; + +namespace Text_Grab.Services; + +public class TtsService +{ + private ITtsEngine _engine = new WindowsSpeechEngine(); + private readonly Channel _queue = Channel.CreateUnbounded(); + private readonly CancellationTokenSource _cts = new(); + + public ITtsEngine Engine + { + set => _engine = value; + } + + public TtsService() + { + _ = Task.Run(DrainLoopAsync); + } + + public void Speak(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return; + + int wordLimit = Settings.Default.TtsSpeakWordLimit; + if (wordLimit > 0) + { + string[] words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (words.Length > wordLimit) + text = string.Join(' ', words[..wordLimit]); + } + + _queue.Writer.TryWrite(text); + } + + private async Task DrainLoopAsync() + { + CancellationToken ct = _cts.Token; + try + { + await foreach (string text in _queue.Reader.ReadAllAsync(ct)) + { + try + { + await _engine.SpeakAsync(text, ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception) + { + // swallow per-item errors so the queue keeps draining + } + } + } + catch (OperationCanceledException) { } + } +} diff --git a/Text-Grab/Services/WindowsSpeechEngine.cs b/Text-Grab/Services/WindowsSpeechEngine.cs new file mode 100644 index 00000000..fa92d954 --- /dev/null +++ b/Text-Grab/Services/WindowsSpeechEngine.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Text_Grab.Interfaces; +using Windows.Media.Core; +using Windows.Media.Playback; +using Windows.Media.SpeechSynthesis; + +namespace Text_Grab.Services; + +public class WindowsSpeechEngine : ITtsEngine +{ + public async Task SpeakAsync(string text, CancellationToken ct) + { + using SpeechSynthesizer synthesizer = new(); + SpeechSynthesisStream stream = await synthesizer.SynthesizeTextToStreamAsync(text).AsTask(); + + ct.ThrowIfCancellationRequested(); + + TaskCompletionSource tcs = new(); + + using MediaPlayer player = new(); + player.Source = MediaSource.CreateFromStream(stream, stream.ContentType); + + player.MediaEnded += (s, e) => tcs.TrySetResult(true); + player.MediaFailed += (s, e) => tcs.TrySetException(new System.Exception(e.ErrorMessage)); + + using CancellationTokenRegistration registration = ct.Register(() => + { + player.Pause(); + tcs.TrySetCanceled(); + }); + + player.Play(); + await tcs.Task; + } +} diff --git a/Text-Grab/Utilities/PostGrabActionManager.cs b/Text-Grab/Utilities/PostGrabActionManager.cs index 3b3c91c1..723dc416 100644 --- a/Text-Grab/Utilities/PostGrabActionManager.cs +++ b/Text-Grab/Utilities/PostGrabActionManager.cs @@ -6,6 +6,7 @@ using System.Windows; using Text_Grab.Interfaces; using Text_Grab.Models; +using Text_Grab.Services; using Wpf.Ui.Controls; namespace Text_Grab.Utilities; @@ -90,6 +91,15 @@ public static List GetDefaultPostGrabActions() ) { OrderNumber = 6.5 + }, + new ButtonInfo( + buttonText: "Speak text", + clickEvent: "SpeakText_Click", + symbolIcon: SymbolRegular.Speaker224, + defaultCheckState: DefaultCheckState.Off + ) + { + OrderNumber = 6.6 } //, //new ButtonInfo( @@ -99,7 +109,7 @@ public static List GetDefaultPostGrabActions() // defaultCheckState: DefaultCheckState.Off //) //{ - // OrderNumber = 6.6 + // OrderNumber = 6.7 //} ]; } @@ -205,6 +215,10 @@ public static async Task ExecutePostGrabAction(ButtonInfo action, PostGr // Don't modify the text break; + case "SpeakText_Click": + Singleton.Instance.Speak(text); + break; + case "Translate_Click": if (WindowsAiUtilities.CanDeviceUseWinAI()) { diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index a19092cc..db90971e 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -93,6 +93,7 @@ public partial class GrabFrame : Window private int translatedWordsCount = 0; private CancellationTokenSource? translationCancellationTokenSource; private const string TargetLanguageMenuHeader = "Target Language"; + private string _lastSpokenFrameText = string.Empty; #endregion Fields @@ -3474,6 +3475,18 @@ private void UpdateFrameText() FrameText = stringBuilder.ToString(); + // Speak if TTS action is enabled, checked, and text has changed + ButtonInfo? speakAction = PostGrabActionManager.GetEnabledPostGrabActions() + .FirstOrDefault(a => a.ClickEvent == "SpeakText_Click"); + if (speakAction is not null + && PostGrabActionManager.GetCheckState(speakAction) + && FrameText != _lastSpokenFrameText + && !string.IsNullOrWhiteSpace(FrameText)) + { + _lastSpokenFrameText = FrameText; + Singleton.Instance.Speak(FrameText); + } + if (IsFromEditWindow && destinationTextBox is not null && AlwaysUpdateEtwCheckBox.IsChecked is true From f1a8a697176cd559a0d3092e03cc8e90011c972a Mon Sep 17 00:00:00 2001 From: Kirsty McNaught Date: Fri, 22 May 2026 11:36:54 +0100 Subject: [PATCH 2/3] feat: speak captured text instead of showing notification Adds SpeakInsteadOfToast setting: when enabled, Text Grab speaks the captured text aloud rather than chiming a notification toast. Defaults to enabled. Includes: - Stop-speaking button in GrabFrame to cancel playback mid-sentence - TTS drain-on-shutdown fix so the queue empties cleanly on exit - Stop() method on TtsService to flush the queue and cancel speech --- Text-Grab/Controls/WordBorder.xaml.cs | 6 ++++- Text-Grab/Pages/GeneralSettings.xaml | 11 ++++++++ Text-Grab/Pages/GeneralSettings.xaml.cs | 17 +++++++++++++ Text-Grab/Properties/Settings.Designer.cs | 14 +++++++++- Text-Grab/Properties/Settings.settings | 3 +++ Text-Grab/Services/TtsService.cs | 28 +++++++++++++++++--- Text-Grab/Utilities/OutputUtilities.cs | 5 +++- Text-Grab/Utilities/WindowUtilities.cs | 16 +++++++++++- Text-Grab/Views/GrabFrame.xaml | 14 ++++++++++ Text-Grab/Views/GrabFrame.xaml.cs | 31 ++++++++++++++++++++--- 10 files changed, 134 insertions(+), 11 deletions(-) diff --git a/Text-Grab/Controls/WordBorder.xaml.cs b/Text-Grab/Controls/WordBorder.xaml.cs index 44a80425..a7856005 100644 --- a/Text-Grab/Controls/WordBorder.xaml.cs +++ b/Text-Grab/Controls/WordBorder.xaml.cs @@ -7,6 +7,7 @@ using System.Windows.Media; using System.Windows.Threading; using Text_Grab.Models; +using Text_Grab.Services; using Text_Grab.Utilities; using Text_Grab.Views; @@ -449,7 +450,10 @@ private void WordBorderControl_MouseDoubleClick(object sender, MouseButtonEventA try { Clipboard.SetDataObject(Word, true); } catch { } - if (AppUtilities.TextGrabSettings.ShowToast + if (AppUtilities.TextGrabSettings.SpeakInsteadOfToast + && !IsFromEditWindow) + Singleton.Instance.Speak(Word); + else if (AppUtilities.TextGrabSettings.ShowToast && !IsFromEditWindow) NotificationUtilities.ShowToast(Word); diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml index d1d07341..41a85ce9 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -127,6 +127,17 @@ Clicking the notification opens the copied text into a new Edit Text Window to display and edit text. + + + Speak text instead of showing notification. + + + + Speaks the grabbed text aloud rather than chiming a notification. + .Instance.DefaultSearcher; ShowToastCheckBox.IsChecked = DefaultSettings.ShowToast; + SpeakInsteadOfToastCheckBox.IsChecked = DefaultSettings.SpeakInsteadOfToast; RunInBackgroundChkBx.IsChecked = DefaultSettings.RunInTheBackground; ReadBarcodesBarcode.IsChecked = DefaultSettings.TryToReadBarcodes; @@ -406,6 +407,22 @@ private void ShowToastCheckBox_Unchecked(object sender, RoutedEventArgs e) DefaultSettings.ShowToast = false; } + private void SpeakInsteadOfToastCheckBox_Checked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + DefaultSettings.SpeakInsteadOfToast = true; + } + + private void SpeakInsteadOfToastCheckBox_Unchecked(object sender, RoutedEventArgs e) + { + if (!settingsSet) + return; + + DefaultSettings.SpeakInsteadOfToast = false; + } + private void WebSearchersComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (!settingsSet diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs index 01b42633..8a2b8a9e 100644 --- a/Text-Grab/Properties/Settings.Designer.cs +++ b/Text-Grab/Properties/Settings.Designer.cs @@ -46,7 +46,19 @@ public bool ShowToast { this["ShowToast"] = value; } } - + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool SpeakInsteadOfToast { + get { + return ((bool)(this["SpeakInsteadOfToast"])); + } + set { + this["SpeakInsteadOfToast"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("Fullscreen")] diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings index 86766019..1e27355c 100644 --- a/Text-Grab/Properties/Settings.settings +++ b/Text-Grab/Properties/Settings.settings @@ -8,6 +8,9 @@ True + + True + Fullscreen diff --git a/Text-Grab/Services/TtsService.cs b/Text-Grab/Services/TtsService.cs index aa642854..5501125e 100644 --- a/Text-Grab/Services/TtsService.cs +++ b/Text-Grab/Services/TtsService.cs @@ -12,6 +12,11 @@ public class TtsService private ITtsEngine _engine = new WindowsSpeechEngine(); private readonly Channel _queue = Channel.CreateUnbounded(); private readonly CancellationTokenSource _cts = new(); + private CancellationTokenSource _speechCts = new(); + private int _pendingCount = 0; + + public event Action? Drained; + public bool IsBusy => Volatile.Read(ref _pendingCount) > 0; public ITtsEngine Engine { @@ -36,28 +41,43 @@ public void Speak(string text) text = string.Join(' ', words[..wordLimit]); } + Interlocked.Increment(ref _pendingCount); _queue.Writer.TryWrite(text); } + public void Stop() + { + _speechCts.Cancel(); + _speechCts = new CancellationTokenSource(); + + while (_queue.Reader.TryRead(out _)) + Interlocked.Decrement(ref _pendingCount); + } + private async Task DrainLoopAsync() { - CancellationToken ct = _cts.Token; + CancellationToken lifecycleCt = _cts.Token; try { - await foreach (string text in _queue.Reader.ReadAllAsync(ct)) + await foreach (string text in _queue.Reader.ReadAllAsync(lifecycleCt)) { try { - await _engine.SpeakAsync(text, ct); + await _engine.SpeakAsync(text, _speechCts.Token); } catch (OperationCanceledException) { - break; + // speech was stopped; continue so the loop can drain remaining items } catch (Exception) { // swallow per-item errors so the queue keeps draining } + finally + { + if (Interlocked.Decrement(ref _pendingCount) == 0) + Drained?.Invoke(); + } } } catch (OperationCanceledException) { } diff --git a/Text-Grab/Utilities/OutputUtilities.cs b/Text-Grab/Utilities/OutputUtilities.cs index 20475866..d80c3ccd 100644 --- a/Text-Grab/Utilities/OutputUtilities.cs +++ b/Text-Grab/Utilities/OutputUtilities.cs @@ -1,5 +1,6 @@ using System.Windows; using System.Windows.Controls; +using Text_Grab.Services; namespace Text_Grab.Utilities; @@ -24,7 +25,9 @@ public static void HandleTextFromOcr(string grabbedText, bool isSingleLine, bool if (!AppUtilities.TextGrabSettings.NeverAutoUseClipboard) try { Clipboard.SetDataObject(grabbedText, true); } catch { } - if (AppUtilities.TextGrabSettings.ShowToast) + if (AppUtilities.TextGrabSettings.SpeakInsteadOfToast) + Singleton.Instance.Speak(grabbedText); + else if (AppUtilities.TextGrabSettings.ShowToast) NotificationUtilities.ShowToast(grabbedText); WindowUtilities.ShouldShutDown(); diff --git a/Text-Grab/Utilities/WindowUtilities.cs b/Text-Grab/Utilities/WindowUtilities.cs index bcf95aed..f9108e85 100644 --- a/Text-Grab/Utilities/WindowUtilities.cs +++ b/Text-Grab/Utilities/WindowUtilities.cs @@ -9,6 +9,7 @@ using System.Windows.Input; using System.Windows.Media; using Text_Grab.Extensions; +using Text_Grab.Services; using Text_Grab.Views; using static OSInterop; @@ -352,7 +353,20 @@ public static void ShouldShutDown() shouldShutDown = true; if (shouldShutDown) - Application.Current.Shutdown(); + { + TtsService tts = Singleton.Instance; + if (tts.IsBusy) + { + void onDrained() + { + tts.Drained -= onDrained; + Application.Current.Dispatcher.Invoke(Application.Current.Shutdown); + } + tts.Drained += onDrained; + } + else + Application.Current.Shutdown(); + } } public static bool GetMousePosition(out Point mousePosition) diff --git a/Text-Grab/Views/GrabFrame.xaml b/Text-Grab/Views/GrabFrame.xaml index e6275d84..fee39c55 100644 --- a/Text-Grab/Views/GrabFrame.xaml +++ b/Text-Grab/Views/GrabFrame.xaml @@ -751,6 +751,7 @@ + + + diff --git a/Text-Grab/Views/GrabFrame.xaml.cs b/Text-Grab/Views/GrabFrame.xaml.cs index db90971e..8277b0ac 100644 --- a/Text-Grab/Views/GrabFrame.xaml.cs +++ b/Text-Grab/Views/GrabFrame.xaml.cs @@ -799,12 +799,16 @@ public async void GrabFrame_Loaded(object sender, RoutedEventArgs e) CheckBottomRowButtonsVis(); + Singleton.Instance.Drained += OnTtsDrained; + if (historyItem is not null) await LoadContentFromHistory(historyItem); } public void GrabFrame_Unloaded(object sender, RoutedEventArgs e) { + Singleton.Instance.Drained -= OnTtsDrained; + Activated -= GrabFrameWindow_Activated; Closed -= Window_Closed; Deactivated -= GrabFrameWindow_Deactivated; @@ -3484,7 +3488,7 @@ private void UpdateFrameText() && !string.IsNullOrWhiteSpace(FrameText)) { _lastSpokenFrameText = FrameText; - Singleton.Instance.Speak(FrameText); + SpeakAndShowStopButton(FrameText); } if (IsFromEditWindow @@ -3658,7 +3662,9 @@ private async void GrabExecuted(object sender, ExecutedRoutedEventArgs e) if (!DefaultSettings.NeverAutoUseClipboard) try { Clipboard.SetDataObject(outputText, true); } catch { } - if (DefaultSettings.ShowToast) + if (DefaultSettings.SpeakInsteadOfToast) + SpeakAndShowStopButton(outputText); + else if (DefaultSettings.ShowToast) NotificationUtilities.ShowToast(outputText); if (CloseOnGrabMenuItem.IsChecked) @@ -3689,13 +3695,32 @@ private void GrabTrimExecuted(object sender, ExecutedRoutedEventArgs e) if (!DefaultSettings.NeverAutoUseClipboard) try { Clipboard.SetDataObject(trimmedSingleLineFrameText, true); } catch { } - if (DefaultSettings.ShowToast) + if (DefaultSettings.SpeakInsteadOfToast) + SpeakAndShowStopButton(trimmedSingleLineFrameText); + else if (DefaultSettings.ShowToast) NotificationUtilities.ShowToast(trimmedSingleLineFrameText); if (CloseOnGrabMenuItem.IsChecked) Close(); } + private void SpeakAndShowStopButton(string text) + { + Singleton.Instance.Speak(text); + StopSpeakingBTN.Visibility = Visibility.Visible; + } + + private void OnTtsDrained() + { + Dispatcher.Invoke(() => StopSpeakingBTN.Visibility = Visibility.Collapsed); + } + + private void StopSpeakingBTN_Click(object sender, RoutedEventArgs e) + { + Singleton.Instance.Stop(); + StopSpeakingBTN.Visibility = Visibility.Collapsed; + } + private void ScrollBehaviorMenuItem_Click(object sender, RoutedEventArgs e) { if (sender is not MenuItem menuItem || !Enum.TryParse(menuItem.Tag.ToString(), out scrollBehavior)) From 4ee634bda1aab3892902b88d32d6c3529d33f873 Mon Sep 17 00:00:00 2001 From: Kirsty McNaught Date: Fri, 22 May 2026 11:37:35 +0100 Subject: [PATCH 3/3] feat: add Voice Output settings page with voice selection Adds a dedicated Voice Output page in Settings containing: - Voice picker populated from SpeechSynthesizer.AllVoices - Word limit setting - Speak-instead-of-notification toggle (moved from General Settings) - Preview button to hear the selected voice WindowsSpeechEngine now applies the saved TtsVoiceName before synthesising. TtsVoiceName setting added (empty = system default). --- Text-Grab/App.config | 3 + Text-Grab/Pages/GeneralSettings.xaml | 43 --------- Text-Grab/Pages/GeneralSettings.xaml.cs | 41 -------- Text-Grab/Pages/VoiceOutputSettings.xaml | 100 ++++++++++++++++++++ Text-Grab/Pages/VoiceOutputSettings.xaml.cs | 82 ++++++++++++++++ Text-Grab/Properties/Settings.Designer.cs | 12 +++ Text-Grab/Properties/Settings.settings | 5 +- Text-Grab/Services/WindowsSpeechEngine.cs | 12 +++ Text-Grab/Views/SettingsWindow.xaml | 13 +++ 9 files changed, 226 insertions(+), 85 deletions(-) create mode 100644 Text-Grab/Pages/VoiceOutputSettings.xaml create mode 100644 Text-Grab/Pages/VoiceOutputSettings.xaml.cs diff --git a/Text-Grab/App.config b/Text-Grab/App.config index a1c719d6..d6f8aa89 100644 --- a/Text-Grab/App.config +++ b/Text-Grab/App.config @@ -52,6 +52,9 @@ False + + + False diff --git a/Text-Grab/Pages/GeneralSettings.xaml b/Text-Grab/Pages/GeneralSettings.xaml index 41a85ce9..869a5cf3 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml +++ b/Text-Grab/Pages/GeneralSettings.xaml @@ -127,18 +127,6 @@ Clicking the notification opens the copied text into a new Edit Text Window to display and edit text. - - - Speak text instead of showing notification. - - - - Speaks the grabbed text aloud rather than chiming a notification. - - - - - - - - - diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs index a92755a8..b419c50c 100644 --- a/Text-Grab/Pages/GeneralSettings.xaml.cs +++ b/Text-Grab/Pages/GeneralSettings.xaml.cs @@ -129,7 +129,6 @@ private async void Page_Loaded(object sender, RoutedEventArgs e) WebSearchersComboBox.SelectedItem = Singleton.Instance.DefaultSearcher; ShowToastCheckBox.IsChecked = DefaultSettings.ShowToast; - SpeakInsteadOfToastCheckBox.IsChecked = DefaultSettings.SpeakInsteadOfToast; RunInBackgroundChkBx.IsChecked = DefaultSettings.RunInTheBackground; ReadBarcodesBarcode.IsChecked = DefaultSettings.TryToReadBarcodes; @@ -140,8 +139,6 @@ private async void Page_Loaded(object sender, RoutedEventArgs e) TryInsertCheckbox.IsChecked = DefaultSettings.TryInsert; InsertDelaySeconds = DefaultSettings.InsertDelay; SecondsTextBox.Text = InsertDelaySeconds.ToString("##.#", System.Globalization.CultureInfo.InvariantCulture); - TtsSpeakWordLimitTextBox.Text = DefaultSettings.TtsSpeakWordLimit.ToString(); - // Context menu integration - only available for unpackaged apps if (!AppUtilities.IsPackaged()) { @@ -183,28 +180,6 @@ private void ValidateTextIsNumber(object sender, TextChangedEventArgs e) } } - private void TtsSpeakWordLimitTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - if (!settingsSet) - return; - - if (sender is System.Windows.Controls.TextBox textBox) - { - bool wasAbleToConvert = int.TryParse(textBox.Text, out int parsedValue); - if (wasAbleToConvert && parsedValue > 0) - { - DefaultSettings.TtsSpeakWordLimit = parsedValue; - TtsWordLimitError.Visibility = Visibility.Collapsed; - textBox.BorderBrush = GoodBrush; - } - else - { - TtsWordLimitError.Visibility = Visibility.Visible; - textBox.BorderBrush = BadBrush; - } - } - } - private void FullScreenRDBTN_Checked(object sender, RoutedEventArgs e) { if (!settingsSet) @@ -407,22 +382,6 @@ private void ShowToastCheckBox_Unchecked(object sender, RoutedEventArgs e) DefaultSettings.ShowToast = false; } - private void SpeakInsteadOfToastCheckBox_Checked(object sender, RoutedEventArgs e) - { - if (!settingsSet) - return; - - DefaultSettings.SpeakInsteadOfToast = true; - } - - private void SpeakInsteadOfToastCheckBox_Unchecked(object sender, RoutedEventArgs e) - { - if (!settingsSet) - return; - - DefaultSettings.SpeakInsteadOfToast = false; - } - private void WebSearchersComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (!settingsSet diff --git a/Text-Grab/Pages/VoiceOutputSettings.xaml b/Text-Grab/Pages/VoiceOutputSettings.xaml new file mode 100644 index 00000000..613a6279 --- /dev/null +++ b/Text-Grab/Pages/VoiceOutputSettings.xaml @@ -0,0 +1,100 @@ + + + + + + + + + + + Speak text instead of showing notification + + + + Speaks the grabbed text aloud rather than showing a notification. + + + + + + Choose from the voices installed on this device. + + + + + + + Stop speaking after this many words. Set to 0 for no limit. + + + + + + + + + +