Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/Dasher.Windows/Controls/SettingsPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1494,7 +1495,9 @@ void RebuildCredentials()
}
}

engineCombo.SelectionChanged += (s, e) =>
Func<Task>? autoLoadVoices = null;

engineCombo.SelectionChanged += async (s, e) =>
{
var idx = engineCombo.SelectedIndex;
if (idx >= 0 && idx < SpeechService.EngineNames.Length)
Expand All @@ -1503,6 +1506,8 @@ void RebuildCredentials()
svc.InvalidateClient();
svc.SaveSettings();
RebuildCredentials();
if (autoLoadVoices != null)
await autoLoadVoices();
}
};

Expand Down Expand Up @@ -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...";
Expand Down Expand Up @@ -1677,7 +1683,9 @@ void RebuildCredentials()
{
loadVoicesBtn.IsEnabled = true;
}
};
}

loadVoicesBtn.Click += async (s, e) => await LoadVoicesIntoCombo();

voiceCombo.SelectionChanged += (s, e) =>
{
Expand Down Expand Up @@ -1721,6 +1729,10 @@ void RebuildCredentials()
svc.SaveSettings();
};

// Auto-load voices on initial show
autoLoadVoices = LoadVoicesIntoCombo;
_ = LoadVoicesIntoCombo();

var sep2 = new Border
{
Height = 1,
Expand Down
3 changes: 2 additions & 1 deletion src/Dasher.Windows/Dasher.Windows.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="DotNetTtsWrapper" Version="1.1.1" />
<PackageReference Include="DotNetTtsWrapper" Version="1.1.2" />
<PackageReference Include="Lucide.Avalonia" Version="0.2.10" />
<PackageReference Include="PostHog" Version="2.7.1" />
<PackageReference Include="System.Speech" Version="10.0.9" />
</ItemGroup>
</Project>
112 changes: 106 additions & 6 deletions src/Dasher.Windows/Speech/SpeechService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -90,6 +92,7 @@ public sealed class SpeechService : IDisposable

private AbstractTtsClient? _client;
private bool _needsRecreate = true;
private SpeechSynthesizer? _sapiSynth;

private SpeechService()
{
Expand All @@ -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;
}
Expand All @@ -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))
Expand All @@ -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 = $"<speak version=\"1.0\" xml:lang=\"{culture}\">" +
$"<prosody pitch=\"{pitchStr}\">{escapedText}</prosody>" +
$"</speak>";

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("&lt;"); break;
case '>': sb.Append("&gt;"); break;
case '&': sb.Append("&amp;"); break;
case '\'': sb.Append("&apos;"); break;
case '"': sb.Append("&quot;"); break;
default: sb.Append(c); break;
}
}
return sb.ToString();
}

public void Stop()
{
try
{
_sapiSynth?.SpeakAsyncCancelAll();
_client?.Stop();
}
catch { }
Expand Down Expand Up @@ -332,6 +430,8 @@ private void LoadSettings()

public void Dispose()
{
_sapiSynth?.Dispose();
_sapiSynth = null;
_client?.Dispose();
_client = null;
}
Expand Down
Loading