Blog Archives
Clipboard to Text To Speech (TTS)
Since two years I am using Text To Speech (TTS). The quality has improved a lot. The spoken text can be understood and the pronunciations have reached good levels; not perfect though. Since Windows 7 there is no need to pay for a professional voice anymore. The Microsoft Windows system voices are sufficient.
First, I wanted to build my own add-on for the Firefox browser. But I quickly realised that there are too many constraints. I am using a nice tool on my Samsung Note 4 to listen to web site texts on a daily basis. That works out very well.
Nevertheless, this tool here is for home PCs.
ClipboardTTS monitors the windows clipboard and displays the current text in a WPF TextBox. In case the CheckBox “Auto” is checked the application starts speaking the text immediately. You can also generate WAV files. Adding this feature only took a few extra lines, otherwise it would not have been worth it. There are only a few use cases.
The Clipboard class offered in .Net does not provide events to monitor clipboard changes. We therefore have to use the old fashioned 32bit Windows functions. Therefore the codes section starts with imports from the windows “user32.dll”. With these you can subscribe to updates. The event WinProc notifies the application about changes. Most of these messages are disregarded in this application. We are only interested in two types of them. The first one is the Windows clipboard chain WM_CHANGECBCHAIN. You have to store the following window of the system clipboard chain, because we must forward messages to that one. This is a weird technology, but who knows what it is good for. For sure it simplifies suppressing messages without the need for any cancellation flag.
WM_DRAWCLIPBOARD is the other type we are interested in. This message tells you that the clipboard context has changed. Have a look at the C# code, you’ll quickly understand.
You could argue that the .Net Clipboard class is not needed, after all the user32.dll can do everything we need. Well, I think we should include as much .Net as possible. This is the right way to stick to the future.
Don’t forget to reference the System.Speech library in Visual Studio.
The application itself is pretty short. This is due to two facts. Windows is offering good SAPI voices and acceptable .Net support to use these voices.
http://msdn.microsoft.com/en-us/library/ee125077%28v=vs.85%29.aspx
I don’t see the point to implement an add-on for Firefox to follow the multi-platform approach. Do I have to re-invent the wheel? And check out the existing add-ons. You can hardly understand the spoken text. Some of these add-ons offer multi-language support. Yeah, but come on! You cannot understand a word. We are not in the 1990s anymore. Computers have learnt speaking very well. What is the language support good for if you cannot understand anything?
<Window x:Class="ClipboardTTS.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="ClipboardTTS" Height="140" Width="263" Topmost="True" Loaded="Window_Loaded" Closed="Window_Closed"> <DockPanel LastChildFill="True"> <DockPanel DockPanel.Dock="Top" LastChildFill="False"> <CheckBox Name="Checkbox_OnOff" DockPanel.Dock="Left" Content="Auto" Margin="5" ToolTip="Speak as soon as the clipboard text changes"/> <Button Content="Say it" DockPanel.Dock="Right" Click="Button_SayIt_Click" Width="50" Margin="5" ToolTip="Start/Stop speaking"/> <Button Name="Button_Save" Content="Save" DockPanel.Dock="Right" Click="Button_Save_Click" Width="50" Margin="5" ToolTip="Create WAV sound file"/> </DockPanel> <ComboBox Name="ComboBox_Voices" DockPanel.Dock="Top" SelectionChanged="ComboBox_Voices_SelectionChanged" ToolTip="Voice"/> <Slider Name="Slider_Volumne" DockPanel.Dock="Top" Minimum="0" Maximum="100" Value="50" ValueChanged="Slider_Volumne_ValueChanged" ToolTip="Volume" /> <TextBox Name="TextBox_Clipboard" TextChanged="TextBox_Clipboard_TextChanged" > hello world ! </TextBox> </DockPanel> </Window>
using System; using System.Collections.ObjectModel; using System.Runtime.InteropServices; using System.Speech.Synthesis; using System.Windows; using System.Windows.Interop; using System.Windows.Media; namespace ClipboardTTS { public partial class MainWindow : Window { private const int WM_DRAWCLIPBOARD = 0x0308; // change notifications private const int WM_CHANGECBCHAIN = 0x030D; // another window is removed from the clipboard viewer chain private const int WM_CLIPBOARDUPDATE = 0x031D; // clipboard changed contents [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr SetClipboardViewer(IntPtr xHWndNewViewer); [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern bool ChangeClipboardChain(IntPtr xHWndRemove, IntPtr xHWndNewNext); [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr SendMessage(IntPtr xHWnd, int xMessage, IntPtr xWParam, IntPtr xLParam); private IntPtr _HWndNextViewer; // next window private HwndSource _HWndSource; // this window private string _Text = string.Empty; private SpeechSynthesizer _SpeechSynthesizer = new SpeechSynthesizer(); public MainWindow() { InitializeComponent(); } // constructor private void StartListeningToClipboard() { WindowInteropHelper lWindowInteropHelper = new WindowInteropHelper(this); _HWndSource = HwndSource.FromHwnd(lWindowInteropHelper.Handle); _HWndSource.AddHook(WinProc); _HWndNextViewer = SetClipboardViewer(_HWndSource.Handle); // set this window as a viewer } // private void StopListeningToClipboard() { ChangeClipboardChain(_HWndSource.Handle, _HWndNextViewer); // remove from cliboard viewer chain _HWndNextViewer = IntPtr.Zero; _HWndSource.RemoveHook(WinProc); } // private void SayIt(string xText) { if (string.IsNullOrWhiteSpace(xText)) return; _SpeechSynthesizer.Volume = (int)Slider_Volumne.Value; _SpeechSynthesizer.SpeakAsync(xText); } // private IntPtr WinProc(IntPtr xHwnd, int xMessageType, IntPtr xWParam, IntPtr xLParam, ref bool xHandled) { switch (xMessageType) { case WM_CHANGECBCHAIN: if (xWParam == _HWndNextViewer) _HWndNextViewer = xLParam; else if (_HWndNextViewer != IntPtr.Zero) SendMessage(_HWndNextViewer, xMessageType, xWParam, xLParam); break; case WM_DRAWCLIPBOARD: SendMessage(_HWndNextViewer, xMessageType, xWParam, xLParam); processWinProcMessage(); break; } return IntPtr.Zero; } // private void processWinProcMessage() { if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(processWinProcMessage); return; } if (!Clipboard.ContainsText()) return; string lPreviousText = _Text; _Text = Clipboard.GetText(); if (_Text.Equals(lPreviousText)) return; // do not play the same text again InsertTextIntoTextBox(_Text); if (Checkbox_OnOff.IsChecked.Value) SayIt(_Text); } // private void InsertTextIntoTextBox(string xText) { if (!TextBox_Clipboard.Dispatcher.CheckAccess()) { TextBox_Clipboard.Dispatcher.Invoke(() => InsertTextIntoTextBox(xText)); return; } TextBox_Clipboard.Text = xText; } // private void Button_SayIt_Click(object xSender, RoutedEventArgs e) { if (_SpeechSynthesizer.State == SynthesizerState.Speaking) { _SpeechSynthesizer.SpeakAsyncCancelAll(); return; } SayIt(TextBox_Clipboard.Text); } // private void TextBox_Clipboard_TextChanged(object xSender, System.Windows.Controls.TextChangedEventArgs e) { _Text = TextBox_Clipboard.Text; } // private void Window_Loaded(object xSender, RoutedEventArgs e) { ReadOnlyCollection<InstalledVoice> lVoices = _SpeechSynthesizer.GetInstalledVoices(); if (lVoices.Count < 1) return; foreach (InstalledVoice xVoice in lVoices) { ComboBox_Voices.Items.Add(xVoice.VoiceInfo.Name); } ComboBox_Voices.SelectedIndex = 0; StartListeningToClipboard(); } // private void Window_Closed(object xSender, EventArgs e) { StopListeningToClipboard(); } // private void ComboBox_Voices_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) { string xVoice = ComboBox_Voices.SelectedItem as string; if (string.IsNullOrWhiteSpace(xVoice)) return; _SpeechSynthesizer.SelectVoice(xVoice); } // private void Slider_Volumne_ValueChanged(object xSender, RoutedPropertyChangedEventArgs<double> e) { _SpeechSynthesizer.Volume = (int)Slider_Volumne.Value; } // private Brush _OldButtonBrush = SystemColors.ControlBrush; private void Button_Save_Click(object xSender, RoutedEventArgs e) { _OldButtonBrush = Button_Save.Background; Button_Save.Background = Brushes.Salmon; Microsoft.Win32.SaveFileDialog lDialog = new Microsoft.Win32.SaveFileDialog(); string lPath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); lDialog.InitialDirectory = lPath; lDialog.FileOk += FileDialog_FileOk; lDialog.Filter = "All Files|*.*|WAV (*.wav)|*.wav"; lDialog.FilterIndex = 2; lDialog.ShowDialog(); } // void FileDialog_FileOk(object xSender, System.ComponentModel.CancelEventArgs e) { Microsoft.Win32.SaveFileDialog lDialog = xSender as Microsoft.Win32.SaveFileDialog; if (lDialog == null) return; if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(() => FileDialog_FileOk(xSender, e)); return; } try { string lPathAndFile = lDialog.FileName; _SpeechSynthesizer.SetOutputToWaveFile(lPathAndFile); _SpeechSynthesizer.SpeakCompleted += SpeechSynthesizer_SpeakCompleted; SayIt(TextBox_Clipboard.Text); Button_Save.Background = _OldButtonBrush; } catch (Exception ex) { MessageBox.Show(ex.Message); } } // void SpeechSynthesizer_SpeakCompleted(object sender, SpeakCompletedEventArgs e) { _SpeechSynthesizer.SetOutputToDefaultAudioDevice(); _SpeechSynthesizer.SpeakCompleted -= SpeechSynthesizer_SpeakCompleted; } // } // class } // namespace