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, diff --git a/src/Dasher.Windows/Dasher.Windows.csproj b/src/Dasher.Windows/Dasher.Windows.csproj index fc7d373..cfd2e75 100644 --- a/src/Dasher.Windows/Dasher.Windows.csproj +++ b/src/Dasher.Windows/Dasher.Windows.csproj @@ -28,8 +28,9 @@ All - + + 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; }