Skip to content

Commit 9338efc

Browse files
authored
Use bounded speech queue for TTS (#55)
* Use bounded speech queue * Prioritize word speech in queue * Cache voice info per culture * Fix speech queue bugs and add interruptible speech - Fix VoiceInfo.Enabled compile error (use InstalledVoice instead) - Remove unused objSpeech field from Controller - Add null safety for InputLanguage.CurrentInputLanguage.Culture - Make speech interruptible: new keypresses cancel current utterance - Add STA thread assertion and documentation for blocking pattern
1 parent 749c073 commit 9338efc

3 files changed

Lines changed: 211 additions & 83 deletions

File tree

App.xaml.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,12 @@ public App()
179179
}
180180
}
181181

182-
void Current_Exit(object sender, ExitEventArgs e)
183-
{
184-
DetachKeyboardHook();
185-
_singleInstanceMutex?.Dispose();
186-
}
182+
void Current_Exit(object sender, ExitEventArgs e)
183+
{
184+
DetachKeyboardHook();
185+
Controller.Instance.Shutdown();
186+
_singleInstanceMutex?.Dispose();
187+
}
187188

188189
/// <summary>
189190
/// Detach the keyboard hook; call during shutdown to prevent calls as we unload
@@ -257,4 +258,4 @@ public static bool AllowKeyboardInput(bool alt, bool control, Keys key)
257258
return true;
258259
}
259260
}
260-
}
261+
}

Controller.cs

Lines changed: 26 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ public class Controller
4343

4444
public bool isOptionsDialogShown { get; set; }
4545
private bool isDrawing = false;
46-
private readonly SpeechSynthesizer objSpeech = new SpeechSynthesizer();
4746
private readonly List<MainWindow> windows = new List<MainWindow>();
47+
private readonly SpeechQueue _speechQueue = new SpeechQueue(5);
4848

4949
private DispatcherTimer timer = new DispatcherTimer();
5050
private Queue<Shape> ellipsesQueue = new Queue<Shape>();
@@ -260,19 +260,19 @@ private void AddFigure(FrameworkElement uie, char c)
260260

261261
// Find the last word typed, if applicable.
262262
string lastWord = this.wordFinder.LastWord(figuresUserControlQueue.Values.First());
263-
if (lastWord != null)
264-
{
265-
foreach (MainWindow window in this.windows)
266-
{
267-
this.wordFinder.AnimateLettersIntoWord(figuresUserControlQueue[window.Name], lastWord);
268-
}
269-
270-
SpeakString(lastWord);
271-
}
272-
else
273-
{
274-
PlaySound(template);
275-
}
263+
if (lastWord != null)
264+
{
265+
foreach (MainWindow window in this.windows)
266+
{
267+
this.wordFinder.AnimateLettersIntoWord(figuresUserControlQueue[window.Name], lastWord);
268+
}
269+
270+
SpeakString(lastWord, true);
271+
}
272+
else
273+
{
274+
PlaySound(template);
275+
}
276276
}
277277

278278
//private static DoubleAnimationUsingKeyFrames ApplyZoomOut(UserControl u)
@@ -334,7 +334,7 @@ public void PlaySound(FigureTemplate template)
334334
{
335335
PlayLaughter();
336336
}
337-
if (objSpeech != null && Settings.Default.Sounds == "Speech")
337+
else if (Settings.Default.Sounds == "Speech")
338338
{
339339
if (template.Letter != null && template.Letter.Length == 1 && Char.IsLetterOrDigit(template.Letter[0]))
340340
{
@@ -419,68 +419,17 @@ private void PlayLaughter()
419419
Win32Audio.PlayWavResource(Utils.GetRandomSoundFile());
420420
}
421421

422-
private void SpeakString(string s)
423-
{
424-
ThreadedSpeak ts = new ThreadedSpeak(s);
425-
ts.Speak();
426-
}
427-
428-
private class ThreadedSpeak
422+
private void SpeakString(string s, bool priority = false)
429423
{
430-
private string Word = null;
431-
SpeechSynthesizer SpeechSynth = new SpeechSynthesizer();
432-
public ThreadedSpeak(string Word)
433-
{
434-
this.Word = Word;
435-
CultureInfo keyboardLanguage = System.Windows.Forms.InputLanguage.CurrentInputLanguage.Culture;
436-
InstalledVoice neededVoice = this.SpeechSynth.GetInstalledVoices(keyboardLanguage).FirstOrDefault();
437-
if (neededVoice == null)
438-
{
439-
//http://superuser.com/questions/590779/how-to-install-more-voices-to-windows-speech
440-
//https://msdn.microsoft.com/en-us/library/windows.media.speechsynthesis.speechsynthesizer.voice.aspx
441-
//http://stackoverflow.com/questions/34776593/speechsynthesizer-selectvoice-fails-with-no-matching-voice-is-installed-or-th
442-
this.Word = "Unsupported Language";
443-
}
444-
else if (!neededVoice.Enabled)
445-
{
446-
this.Word = "Voice Disabled";
447-
}
448-
else
449-
{
450-
try
451-
{
452-
this.SpeechSynth.SelectVoice(neededVoice.VoiceInfo.Name);
453-
}
454-
catch (Exception ex)
455-
{
456-
Debug.Assert(false, ex.ToString());
457-
}
458-
}
459-
460-
SpeechSynth.Rate = -1;
461-
SpeechSynth.Volume = 100;
462-
}
463-
public void Speak()
464-
{
465-
Thread oThread = new Thread(new ThreadStart(this.Start));
466-
oThread.Start();
467-
}
468-
private void Start()
469-
{
470-
try
471-
{
472-
SpeechSynth.Speak(Word);
473-
}
474-
catch (Exception e)
475-
{
476-
System.Diagnostics.Trace.WriteLine(e.ToString());
477-
}
478-
finally
479-
{
480-
SpeechSynth?.Dispose();
481-
}
482-
}
483-
}
424+
var culture = WinForms.InputLanguage.CurrentInputLanguage?.Culture
425+
?? System.Globalization.CultureInfo.CurrentUICulture;
426+
_speechQueue.Enqueue(s, culture, priority);
427+
}
428+
429+
public void Shutdown()
430+
{
431+
_speechQueue.Dispose();
432+
}
484433

485434
public void ShowOptionsDialog()
486435
{
@@ -607,4 +556,4 @@ public void LostMouseCapture(MainWindow main, MouseEventArgs e)
607556
if (isDrawing) isDrawing = false;
608557
}
609558
}
610-
}
559+
}

SpeechQueue.cs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using System.Globalization;
5+
using System.Linq;
6+
using System.Speech.Synthesis;
7+
using System.Threading;
8+
using System.Threading.Channels;
9+
10+
namespace BabySmash
11+
{
12+
internal sealed class SpeechQueue : IDisposable
13+
{
14+
private readonly Channel<SpeechItem> _channel;
15+
private readonly Thread _workerThread;
16+
private readonly CancellationTokenSource _cts = new();
17+
private SpeechSynthesizer _synth;
18+
private readonly Dictionary<string, InstalledVoice> _voiceCache = new();
19+
private readonly object _voiceLock = new();
20+
21+
private readonly struct SpeechItem
22+
{
23+
public SpeechItem(string text, CultureInfo culture)
24+
{
25+
Text = text;
26+
Culture = culture;
27+
}
28+
29+
public string Text { get; }
30+
public CultureInfo Culture { get; }
31+
}
32+
33+
public SpeechQueue(int capacity = 5)
34+
{
35+
var options = new BoundedChannelOptions(capacity)
36+
{
37+
SingleReader = true,
38+
SingleWriter = false,
39+
FullMode = BoundedChannelFullMode.DropOldest
40+
};
41+
_channel = Channel.CreateBounded<SpeechItem>(options);
42+
43+
_workerThread = new Thread(WorkerLoop)
44+
{
45+
IsBackground = true
46+
};
47+
_workerThread.SetApartmentState(ApartmentState.STA);
48+
_workerThread.Start();
49+
}
50+
51+
public void Enqueue(string text, CultureInfo culture, bool priority = false)
52+
{
53+
if (string.IsNullOrWhiteSpace(text))
54+
{
55+
return;
56+
}
57+
58+
if (priority)
59+
{
60+
// Make room for a high-priority utterance (e.g., a detected word).
61+
while (_channel.Reader.TryRead(out _))
62+
{
63+
}
64+
}
65+
66+
// Signal that new speech is pending (worker will cancel current speech)
67+
_newItemPending = true;
68+
_channel.Writer.TryWrite(new SpeechItem(text, culture));
69+
}
70+
71+
private volatile bool _newItemPending;
72+
73+
private void WorkerLoop()
74+
{
75+
// SpeechSynthesizer is a COM object that requires STA thread
76+
Debug.Assert(Thread.CurrentThread.GetApartmentState() == ApartmentState.STA,
77+
"SpeechSynthesizer requires STA thread");
78+
79+
_synth = new SpeechSynthesizer
80+
{
81+
Rate = -1, // Slower for baby clarity
82+
Volume = 100
83+
};
84+
85+
try
86+
{
87+
var reader = _channel.Reader;
88+
// Using blocking wait (not await) to ensure we stay on this STA thread.
89+
// async/await could switch threads after await, breaking STA requirements.
90+
while (reader.WaitToReadAsync(_cts.Token).AsTask().GetAwaiter().GetResult())
91+
{
92+
while (reader.TryRead(out var item))
93+
{
94+
SpeakItem(item);
95+
}
96+
}
97+
}
98+
catch (OperationCanceledException)
99+
{
100+
// Normal shutdown.
101+
}
102+
finally
103+
{
104+
_synth?.Dispose();
105+
}
106+
}
107+
108+
private void SpeakItem(SpeechItem item)
109+
{
110+
_newItemPending = false;
111+
112+
string textToSpeak = item.Text;
113+
var voice = GetCachedVoice(item.Culture);
114+
if (voice == null)
115+
{
116+
textToSpeak = "Unsupported Language";
117+
}
118+
else if (!voice.Enabled)
119+
{
120+
textToSpeak = "Voice Disabled";
121+
}
122+
else
123+
{
124+
try
125+
{
126+
_synth.SelectVoice(voice.VoiceInfo.Name);
127+
}
128+
catch
129+
{
130+
// Keep default voice.
131+
}
132+
}
133+
134+
try
135+
{
136+
// Use async speech so we can cancel if new input arrives
137+
_synth.SpeakAsync(textToSpeak);
138+
139+
// Wait for speech to complete, but cancel if new item arrives
140+
while (_synth.State == SynthesizerState.Speaking)
141+
{
142+
Thread.Sleep(50);
143+
if (_newItemPending)
144+
{
145+
_synth.SpeakAsyncCancelAll();
146+
break;
147+
}
148+
}
149+
}
150+
catch
151+
{
152+
// Swallow speech errors to keep the app responsive.
153+
}
154+
}
155+
156+
public void Dispose()
157+
{
158+
_cts.Cancel();
159+
_channel.Writer.TryComplete();
160+
}
161+
162+
private InstalledVoice GetCachedVoice(CultureInfo culture)
163+
{
164+
var key = culture.Name;
165+
lock (_voiceLock)
166+
{
167+
if (_voiceCache.TryGetValue(key, out var cached))
168+
{
169+
return cached;
170+
}
171+
172+
var voice = _synth.GetInstalledVoices(culture).FirstOrDefault();
173+
_voiceCache[key] = voice;
174+
return voice;
175+
}
176+
}
177+
}
178+
}

0 commit comments

Comments
 (0)