From 18db3458ee063af50292b5272c7aaf3196cf33aa Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 19 Jun 2026 16:57:50 +0100 Subject: [PATCH 1/3] Bypass DotNetTtsWrapper for SAPI: fix Rate/Pitch/Volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DotNetTtsWrapper's SapiTtsClient ignores TtsOptions (Rate, Pitch, Volume). Until the wrapper fix (AACTools/dotnet-tts-wrapper PR) is published as a new NuGet version, bypass it for SAPI: - Use System.Speech.SpeechSynthesizer directly in SpeechService - Rate: mapped to synthesizer.Rate (-10..10) - Volume: set directly on synthesizer (0-100) - Pitch: via SSML (no native property) - Voice enumeration via SpeechSynthesizer.GetInstalledVoices() Note: 32-bit SAPI5 voices still invisible because the app runs 64-bit and System.Speech is bitness-bound. Requires a 32-bit surrogate process to resolve — tracked separately. Signed-off-by: will wade --- src/Dasher.Windows/Dasher.Windows.csproj | 1 + src/Dasher.Windows/Speech/SpeechService.cs | 112 +++++++++++++++++++-- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/Dasher.Windows/Dasher.Windows.csproj b/src/Dasher.Windows/Dasher.Windows.csproj index fc7d373..e9075b0 100644 --- a/src/Dasher.Windows/Dasher.Windows.csproj +++ b/src/Dasher.Windows/Dasher.Windows.csproj @@ -31,5 +31,6 @@ + diff --git a/src/Dasher.Windows/Speech/SpeechService.cs b/src/Dasher.Windows/Speech/SpeechService.cs index 5a11b11..6ae633b 100644 --- a/src/Dasher.Windows/Speech/SpeechService.cs +++ b/src/Dasher.Windows/Speech/SpeechService.cs @@ -2,8 +2,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Speech.Synthesis; using System.Text.Json; using System.Threading.Tasks; +using System.Xml; using DotNetTtsWrapper.Models; namespace Dasher.Windows.Speech; @@ -90,6 +92,7 @@ public sealed class SpeechService : IDisposable private AbstractTtsClient? _client; private bool _needsRecreate = true; + private SpeechSynthesizer? _sapiSynth; private SpeechService() { @@ -102,14 +105,34 @@ public async Task LoadVoicesAsync() ErrorMessage = null; try { - var client = GetOrCreateClient(); - if (client == null) + if (SelectedEngine == "sapi") { - AvailableVoices = []; - return; + _sapiSynth?.Dispose(); + _sapiSynth = new SpeechSynthesizer(); + var installed = _sapiSynth.GetInstalledVoices(); + AvailableVoices = installed + .Where(v => v.Enabled) + .Select(v => new TtsVoice + { + Id = v.VoiceInfo.Name, + Name = v.VoiceInfo.Name, + LanguageCodes = + [ + new() { Bcp47 = v.VoiceInfo.Culture?.Name ?? "", Display = v.VoiceInfo.Culture?.DisplayName ?? "" } + ] + }) + .ToList(); + } + else + { + var client = GetOrCreateClient(); + if (client == null) + { + AvailableVoices = []; + return; + } + AvailableVoices = await client.GetVoicesAsync(); } - var voices = await client.GetVoicesAsync(); - AvailableVoices = voices; if (SelectedVoiceId != null && !AvailableVoices.Any(v => v.Id == SelectedVoiceId)) SelectedVoiceId = AvailableVoices.FirstOrDefault()?.Id; } @@ -131,6 +154,12 @@ public async Task SpeakAsync(string text, bool interrupt = true) ErrorMessage = null; try { + if (SelectedEngine == "sapi") + { + await SpeakSapiAsync(text, interrupt); + return; + } + var client = GetOrCreateClient(); if (client == null) return; if (!string.IsNullOrEmpty(SelectedVoiceId)) @@ -155,10 +184,79 @@ public async Task SpeakAsync(string text, bool interrupt = true) } } + private async Task SpeakSapiAsync(string text, bool interrupt) + { + var synth = _sapiSynth ??= new SpeechSynthesizer(); + + if (interrupt) + synth.SpeakAsyncCancelAll(); + + // Select voice + if (!string.IsNullOrEmpty(SelectedVoiceId)) + { + try { synth.SelectVoice(SelectedVoiceId); } catch { } + } + + // Apply Rate (-10 to 10) + synth.Rate = SpeechRate switch + { + SpeechRate.XSlow => -5, + SpeechRate.Slow => -2, + SpeechRate.Medium => 0, + SpeechRate.Fast => 3, + SpeechRate.XFast => 6, + _ => 0 + }; + + // Apply Volume (0-100) + synth.Volume = Math.Clamp(SpeechVolume, 0, 100); + + // Build SSML for pitch support (SpeechSynthesizer has no Pitch property) + var pitchStr = SpeechPitch switch + { + SpeechPitch.XLow => "x-low", + SpeechPitch.Low => "low", + SpeechPitch.Medium => "medium", + SpeechPitch.High => "high", + SpeechPitch.XHigh => "x-high", + _ => "medium" + }; + + var escapedText = XmlEncode(text); + var culture = "en-US"; + try { culture = synth.GetInstalledVoices().FirstOrDefault(v => v.VoiceInfo.Name == SelectedVoiceId)?.VoiceInfo.Culture?.Name ?? "en-US"; } catch { } + + var ssml = $"" + + $"{escapedText}" + + $""; + + synth.SetOutputToDefaultAudioDevice(); + await Task.Run(() => synth.SpeakSsml(ssml)); + } + + private static string XmlEncode(string text) + { + var sb = new System.Text.StringBuilder(text.Length + 20); + foreach (var c in text) + { + switch (c) + { + case '<': sb.Append("<"); break; + case '>': sb.Append(">"); break; + case '&': sb.Append("&"); break; + case '\'': sb.Append("'"); break; + case '"': sb.Append("""); break; + default: sb.Append(c); break; + } + } + return sb.ToString(); + } + public void Stop() { try { + _sapiSynth?.SpeakAsyncCancelAll(); _client?.Stop(); } catch { } @@ -332,6 +430,8 @@ private void LoadSettings() public void Dispose() { + _sapiSynth?.Dispose(); + _sapiSynth = null; _client?.Dispose(); _client = null; } From 35c974f0d7bd3f36bd31f4bb13767b70c0e3ee73 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 19 Jun 2026 17:16:07 +0100 Subject: [PATCH 2/3] Auto-load voices when TTS engine changes Voices now load automatically when: - Speech settings panel is first shown (no need to click Load Voices) - User changes the TTS engine dropdown Signed-off-by: will wade --- src/Dasher.Windows/Controls/SettingsPanel.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Dasher.Windows/Controls/SettingsPanel.cs b/src/Dasher.Windows/Controls/SettingsPanel.cs index 65ca0da..2992453 100644 --- a/src/Dasher.Windows/Controls/SettingsPanel.cs +++ b/src/Dasher.Windows/Controls/SettingsPanel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; @@ -1494,7 +1495,9 @@ void RebuildCredentials() } } - engineCombo.SelectionChanged += (s, e) => + Func? autoLoadVoices = null; + + engineCombo.SelectionChanged += async (s, e) => { var idx = engineCombo.SelectedIndex; if (idx >= 0 && idx < SpeechService.EngineNames.Length) @@ -1503,6 +1506,8 @@ void RebuildCredentials() svc.InvalidateClient(); svc.SaveSettings(); RebuildCredentials(); + if (autoLoadVoices != null) + await autoLoadVoices(); } }; @@ -1648,7 +1653,8 @@ void RebuildCredentials() _panel.Children.Add(voiceHeader); - loadVoicesBtn.Click += async (s, e) => + // Voice loading logic — used by button click AND auto-load on engine change + async Task LoadVoicesIntoCombo() { loadVoicesBtn.IsEnabled = false; voiceCountLabel.Text = "Loading..."; @@ -1677,7 +1683,9 @@ void RebuildCredentials() { loadVoicesBtn.IsEnabled = true; } - }; + } + + loadVoicesBtn.Click += async (s, e) => await LoadVoicesIntoCombo(); voiceCombo.SelectionChanged += (s, e) => { @@ -1721,6 +1729,10 @@ void RebuildCredentials() svc.SaveSettings(); }; + // Auto-load voices on initial show + autoLoadVoices = LoadVoicesIntoCombo; + _ = LoadVoicesIntoCombo(); + var sep2 = new Border { Height = 1, From 74290b1b178bfd7ac9400c7c7e790d3981660e65 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 19 Jun 2026 17:48:12 +0100 Subject: [PATCH 3/3] Update DotNetTtsWrapper to v1.1.2 Fixes Rate, Pitch, and Volume for SAPI voices in the wrapper itself. Dasher-Windows still has the direct SAPI bypass which can be removed in a follow-up once the wrapper fix is verified. Signed-off-by: will wade --- src/Dasher.Windows/Dasher.Windows.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Dasher.Windows/Dasher.Windows.csproj b/src/Dasher.Windows/Dasher.Windows.csproj index e9075b0..cfd2e75 100644 --- a/src/Dasher.Windows/Dasher.Windows.csproj +++ b/src/Dasher.Windows/Dasher.Windows.csproj @@ -28,7 +28,7 @@ All - +