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/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/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/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..869a5cf3 100644
--- a/Text-Grab/Pages/GeneralSettings.xaml
+++ b/Text-Grab/Pages/GeneralSettings.xaml
@@ -127,7 +127,6 @@
Clicking the notification opens the copied text into a new Edit Text Window to display and edit text.
-
+
diff --git a/Text-Grab/Pages/GeneralSettings.xaml.cs b/Text-Grab/Pages/GeneralSettings.xaml.cs
index 987e789f..b419c50c 100644
--- a/Text-Grab/Pages/GeneralSettings.xaml.cs
+++ b/Text-Grab/Pages/GeneralSettings.xaml.cs
@@ -139,7 +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);
-
// Context menu integration - only available for unpackaged apps
if (!AppUtilities.IsPackaged())
{
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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Text-Grab/Pages/VoiceOutputSettings.xaml.cs b/Text-Grab/Pages/VoiceOutputSettings.xaml.cs
new file mode 100644
index 00000000..831ca705
--- /dev/null
+++ b/Text-Grab/Pages/VoiceOutputSettings.xaml.cs
@@ -0,0 +1,82 @@
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using Text_Grab.Properties;
+using Text_Grab.Services;
+using Text_Grab.Utilities;
+using Windows.Media.SpeechSynthesis;
+
+namespace Text_Grab.Pages;
+
+public partial class VoiceOutputSettings : Page
+{
+ private readonly Settings DefaultSettings = AppUtilities.TextGrabSettings;
+ private bool _loaded = false;
+
+ public VoiceOutputSettings()
+ {
+ InitializeComponent();
+ }
+
+ private void Page_Loaded(object sender, RoutedEventArgs e)
+ {
+ SpeakInsteadOfToastToggle.IsChecked = DefaultSettings.SpeakInsteadOfToast;
+
+ VoiceComboBox.Items.Clear();
+ foreach (VoiceInformation voice in SpeechSynthesizer.AllVoices.OrderBy(v => v.DisplayName))
+ VoiceComboBox.Items.Add(voice.DisplayName);
+
+ string savedVoice = DefaultSettings.TtsVoiceName;
+ if (!string.IsNullOrEmpty(savedVoice) && VoiceComboBox.Items.Contains(savedVoice))
+ VoiceComboBox.SelectedItem = savedVoice;
+ else
+ VoiceComboBox.SelectedIndex = 0;
+
+ TtsSpeakWordLimitTextBox.Text = DefaultSettings.TtsSpeakWordLimit.ToString();
+
+ _loaded = true;
+ }
+
+ private void SpeakInsteadOfToastToggle_Checked(object sender, RoutedEventArgs e)
+ {
+ if (!_loaded)
+ return;
+
+ DefaultSettings.SpeakInsteadOfToast = SpeakInsteadOfToastToggle.IsChecked is true;
+ DefaultSettings.Save();
+ }
+
+ private void VoiceComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (!_loaded)
+ return;
+
+ if (VoiceComboBox.SelectedItem is string voiceName)
+ {
+ DefaultSettings.TtsVoiceName = voiceName;
+ DefaultSettings.Save();
+ }
+ }
+
+ private void TtsSpeakWordLimitTextBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ if (!_loaded)
+ return;
+
+ if (int.TryParse(TtsSpeakWordLimitTextBox.Text, out int parsedValue) && parsedValue > 0)
+ {
+ DefaultSettings.TtsSpeakWordLimit = parsedValue;
+ DefaultSettings.Save();
+ TtsWordLimitError.Visibility = Visibility.Collapsed;
+ }
+ else
+ {
+ TtsWordLimitError.Visibility = Visibility.Visible;
+ }
+ }
+
+ private void PreviewVoiceButton_Click(object sender, RoutedEventArgs e)
+ {
+ Singleton.Instance.Speak("Hello, this is a preview of the selected voice.");
+ }
+}
diff --git a/Text-Grab/Properties/Settings.Designer.cs b/Text-Grab/Properties/Settings.Designer.cs
index 073f399f..4ce63205 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")]
@@ -958,5 +970,29 @@ 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;
+ }
+ }
+
+ [global::System.Configuration.UserScopedSettingAttribute()]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Configuration.DefaultSettingValueAttribute("")]
+ public string TtsVoiceName {
+ get {
+ return ((string)(this["TtsVoiceName"]));
+ }
+ set {
+ this["TtsVoiceName"] = value;
+ }
+ }
}
}
diff --git a/Text-Grab/Properties/Settings.settings b/Text-Grab/Properties/Settings.settings
index be4eb667..fc7d13a3 100644
--- a/Text-Grab/Properties/Settings.settings
+++ b/Text-Grab/Properties/Settings.settings
@@ -8,6 +8,9 @@
True
+
+ True
+
Fullscreen
@@ -57,7 +60,7 @@
False
- False
+ True
True
@@ -236,5 +239,11 @@
False
+
+ 100
+
+
+
+
diff --git a/Text-Grab/Services/TtsService.cs b/Text-Grab/Services/TtsService.cs
new file mode 100644
index 00000000..5501125e
--- /dev/null
+++ b/Text-Grab/Services/TtsService.cs
@@ -0,0 +1,85 @@
+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();
+ private CancellationTokenSource _speechCts = new();
+ private int _pendingCount = 0;
+
+ public event Action? Drained;
+ public bool IsBusy => Volatile.Read(ref _pendingCount) > 0;
+
+ 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]);
+ }
+
+ 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 lifecycleCt = _cts.Token;
+ try
+ {
+ await foreach (string text in _queue.Reader.ReadAllAsync(lifecycleCt))
+ {
+ try
+ {
+ await _engine.SpeakAsync(text, _speechCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // 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/Services/WindowsSpeechEngine.cs b/Text-Grab/Services/WindowsSpeechEngine.cs
new file mode 100644
index 00000000..e9eb7853
--- /dev/null
+++ b/Text-Grab/Services/WindowsSpeechEngine.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Text_Grab.Interfaces;
+using Text_Grab.Properties;
+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();
+
+ string voiceName = Settings.Default.TtsVoiceName;
+ if (!string.IsNullOrEmpty(voiceName))
+ {
+ VoiceInformation? voice = SpeechSynthesizer.AllVoices
+ .FirstOrDefault(v => v.DisplayName == voiceName);
+ if (voice is not null)
+ synthesizer.Voice = voice;
+ }
+
+ 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/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/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/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 a19092cc..8277b0ac 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
@@ -798,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;
@@ -3474,6 +3479,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;
+ SpeakAndShowStopButton(FrameText);
+ }
+
if (IsFromEditWindow
&& destinationTextBox is not null
&& AlwaysUpdateEtwCheckBox.IsChecked is true
@@ -3645,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)
@@ -3676,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))
diff --git a/Text-Grab/Views/SettingsWindow.xaml b/Text-Grab/Views/SettingsWindow.xaml
index 37a52c3b..575b9bb7 100644
--- a/Text-Grab/Views/SettingsWindow.xaml
+++ b/Text-Grab/Views/SettingsWindow.xaml
@@ -155,6 +155,19 @@
+
+
+
+ Voice
+
+ Output
+
+
+
+
+
+
+