diff --git a/.github/workflows/msbuild.yml b/.github/workflows/msbuild.yml
index e47fbe7..5e504f6 100644
--- a/.github/workflows/msbuild.yml
+++ b/.github/workflows/msbuild.yml
@@ -38,22 +38,6 @@ jobs:
sherpa-config/SherpaOnnxConfig.exe
sherpa-config/merged_models.json
- build-engine-config:
- name: Build EngineConfig
- runs-on: windows-latest
- permissions:
- contents: read
- steps:
- - uses: actions/checkout@v4
-
- - name: Build EngineConfig (self-contained, x64)
- run: dotnet publish EngineConfig\EngineConfig.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o engine-config
-
- - uses: actions/upload-artifact@v4
- with:
- name: engine-config
- path: engine-config/EngineConfig.exe
-
build-voice-garden-ui:
name: Build VoiceGarden.UI
runs-on: windows-2022
@@ -71,11 +55,11 @@ jobs:
run: |
dotnet publish VoiceGarden.UI\VoiceGarden.UI.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishReadyToRun=false -o ${{github.workspace}}\ui-publish
- - name: Copy branding.json
+ - name: Copy
shell: pwsh
run: |
- if (Test-Path config\branding.json) {
- Copy-Item config\branding.json ui-publish\ -Force
+ if (Test-Path config\) {
+ Copy-Item config\ ui-publish\ -Force
}
- uses: actions/upload-artifact@v4
@@ -86,7 +70,7 @@ jobs:
build-utilities:
name: Build submodules
runs-on: windows-2022
- needs: [build-sherpa-config, build-engine-config]
+ needs: [build-sherpa-config]
permissions:
contents: read
strategy:
@@ -128,13 +112,6 @@ jobs:
name: sherpa-onnx-config
path: ${{github.workspace}}\out\
- - name: Download EngineConfig and copy to utilities output
- if: matrix.platform == 'x86' || matrix.platform == 'x64'
- uses: actions/download-artifact@v4
- with:
- name: engine-config
- path: ${{github.workspace}}\out\
-
- name: Download Sherpa model metadata for ARM64 utilities
if: matrix.platform == 'ARM64'
uses: actions/download-artifact@v4
@@ -147,12 +124,12 @@ jobs:
shell: pwsh
run: Remove-Item out\SherpaOnnxConfig.exe -ErrorAction SilentlyContinue
- - name: Copy branding.json to output
+ - name: Copy to output
if: matrix.platform == 'x86'
shell: pwsh
run: |
- if (Test-Path .\config\branding.json) {
- Copy-Item -Path .\config\branding.json -Destination out\branding.json -Force
+ if (Test-Path .\config\) {
+ Copy-Item -Path .\config\ -Destination out\ -Force
}
- uses: actions/upload-artifact@v4
@@ -218,41 +195,10 @@ jobs:
name: main-${{matrix.platform}}
path: out
- build-dotnet-adapter:
- name: Build .NET TTS Adapter
- runs-on: windows-2022
- permissions:
- contents: read
- strategy:
- matrix:
- platform: [x86, x64]
- steps:
- - uses: actions/checkout@v4
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: '8.0.x'
-
- - name: Publish .NET adapter
- shell: pwsh
- run: |
- $rid = "${{matrix.platform}}" -eq "x86" ? "win-x86" : "win-x64"
- dotnet publish VoiceGardenSAPIAdapter.Net\VoiceGardenSAPIAdapter.Net.csproj `
- -c ${{env.BUILD_CONFIGURATION}} `
- -r $rid `
- --self-contained false `
- -o ${{github.workspace}}\out
-
- - uses: actions/upload-artifact@v4
- with:
- name: dotnet-adapter-${{matrix.platform}}
- path: out
-
build-setup:
name: Build MSI setup
runs-on: windows-2022
- needs: [build-sherpa-config, build-engine-config, build-voice-garden-ui, build-utilities, build-main, build-dotnet-adapter]
+ needs: [build-sherpa-config, build-voice-garden-ui, build-utilities, build-main]
permissions:
contents: read
steps:
@@ -279,21 +225,17 @@ jobs:
Copy-Item artifacts\utilities-ARM64\* payload\ARM64\ -Recurse -Force
Copy-Item artifacts\main-ARM64\* payload\ARM64\ -Recurse -Force
- # .NET adapter files (but preserve C++ adapter's native SherpaOnnx DLLs)
- $nativeDlls = @("sherpa-onnx-c-api.dll", "sherpa-onnx.dll", "onnxruntime.dll", "onnxruntime_providers_shared.dll")
- Get-ChildItem artifacts\dotnet-adapter-x86 -File | Where-Object { $_.Name -notin $nativeDlls } | ForEach-Object { Copy-Item $_.FullName payload\x86\ -Force }
- Get-ChildItem artifacts\dotnet-adapter-x86 -Directory | ForEach-Object { Copy-Item $_.FullName payload\x86\ -Recurse -Force }
- Get-ChildItem artifacts\dotnet-adapter-x64 -File | Where-Object { $_.Name -notin $nativeDlls } | ForEach-Object { Copy-Item $_.FullName payload\x64\ -Force }
- Get-ChildItem artifacts\dotnet-adapter-x64 -Directory | ForEach-Object { Copy-Item $_.FullName payload\x64\ -Recurse -Force }
-
# VoiceGarden.UI.exe (the main configuration app)
Copy-Item artifacts\voice-garden-ui\VoiceGarden.UI.exe payload\ -Force
- # Branding
- if (Test-Path payload\x86\branding.json) {
- Copy-Item payload\x86\branding.json payload\branding.json -Force
- } elseif (Test-Path .\config\branding.json) {
- Copy-Item .\config\branding.json payload\branding.json -Force
+ # Rust TTS wrapper native DLL (from NuGet package)
+ dotnet restore VoiceGarden.UI\VoiceGarden.UI.csproj
+ $rustDll = Get-ChildItem "$env:USERPROFILE\.nuget\packages\rustttswrapper.bindings" -Recurse -Filter "rust_tts_wrapper.dll" | Select-Object -First 1
+ if ($rustDll) {
+ Copy-Item $rustDll.FullName payload\x64\ -Force
+ Write-Host "Copied rust_tts_wrapper.dll to payload\x64\"
+ } else {
+ Write-Host "WARNING: rust_tts_wrapper.dll not found in NuGet cache"
}
- name: Build MSI
@@ -319,7 +261,6 @@ jobs:
path: |
installer-output/setup.exe
installer-output/VoiceGardenSAPIAdapter.msi
- installer-output/branding.json
release:
runs-on: ubuntu-latest
@@ -356,7 +297,7 @@ jobs:
# MSI + setup.exe
cp setup-msi/VoiceGardenSAPIAdapter.msi . 2>/dev/null || true
cp setup-exe/setup.exe . 2>/dev/null || true
- cp setup-exe/branding.json . 2>/dev/null || true
+ cp setup-exe/ . 2>/dev/null || true
# Debug symbols
zip -r debug_symbols.zip x86 x64 ARM64 -i "*.pdb"
@@ -367,8 +308,7 @@ jobs:
# Full release layout (everything)
zip -r VoiceGardenSAPIAdapter_${ver}_release-layout.zip \
- x86 x64 ARM64 VoiceGarden.UI.exe setup.exe VoiceGardenSAPIAdapter.msi \
- branding.json \
+ x86 x64 ARM64 VoiceGarden.UI.exe setup.exe VoiceGardenSAPIAdapter.msi \ \
-i "*.exe" "*.dll" "*.json" "*.msi"
- name: Create release
@@ -391,6 +331,8 @@ jobs:
${{github.workspace}}/artifacts/x64/**
${{github.workspace}}/artifacts/ARM64/**
${{github.workspace}}/artifacts/VoiceGarden.UI.exe
- ${{github.workspace}}/artifacts/branding.json
+ ${{github.workspace}}/artifacts/
${{github.workspace}}/artifacts/VoiceGardenSAPIAdapter.msi
${{github.workspace}}/artifacts/setup.exe
+
+
diff --git a/.gitignore b/.gitignore
index b9d5646..d6298e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -403,3 +403,10 @@ engine-config/
vg_promote_output.txt
+
+# Old archived code
+archive/
+
+out-full/
+out-rust-tts/
+payload/
diff --git a/EngineConfig/EngineConfig.csproj b/EngineConfig/EngineConfig.csproj
deleted file mode 100644
index a81fd33..0000000
--- a/EngineConfig/EngineConfig.csproj
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
- Exe
- net8.0
- enable
- enable
- EngineConfig
- EngineConfig
-
-
-
-
-
-
-
diff --git a/EngineConfig/Program.cs b/EngineConfig/Program.cs
deleted file mode 100644
index 3d83a76..0000000
--- a/EngineConfig/Program.cs
+++ /dev/null
@@ -1,533 +0,0 @@
-using System.ComponentModel;
-using System.Diagnostics;
-using System.Runtime.InteropServices;
-using System.Security;
-using System.Text.Json;
-
-namespace EngineConfig;
-
-internal static class Program
-{
- private static readonly string SapiTokensRoot = @"SOFTWARE\Microsoft\Speech\Voices\Tokens";
- private static readonly string TtsEngineClsid = "{013AB33B-AD1A-401C-8BEE-F6E2B046A94E}";
-
- [DllImport("kernel32.dll")]
- private static extern bool AllocConsole();
-
- [STAThread]
- private static int Main(string[] args)
- {
- if (args.Length == 0)
- {
- ShowHelp();
- return 0;
- }
-
- string command = args[0].ToLowerInvariant();
-
- try
- {
- return command switch
- {
- "engines" => ListEngines(),
- "voices" => ListVoices(args),
- "validate" => ValidateCredentials(args),
- "promote" => PromoteVoice(args),
- "unpromote" => UnpromoteVoice(args),
- "promoted" => ListPromoted(),
- "test" => TestVoice(args),
- "-h" or "--help" or "/?" => ShowHelp(),
- _ => UnknownCommand(command)
- };
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error: {ex.Message}");
- return 1;
- }
- }
-
- private static int ListEngines()
- {
- Console.WriteLine("Supported TTS Engines");
- Console.WriteLine("=====================");
- Console.WriteLine();
-
- var engines = new[]
- {
- ("azure", "Azure Cognitive Services Speech", new[] { "AZURE_SPEECH_KEY", "AZURE_SPEECH_REGION" }),
- ("openai", "OpenAI TTS", new[] { "OPENAI_API_KEY" }),
- ("elevenlabs", "ElevenLabs", new[] { "ELEVENLABS_API_KEY" }),
- ("google", "Google Cloud TTS", new[] { "GOOGLE_API_KEY" }),
- ("polly", "AWS Polly", new[] { "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION" }),
- ("cartesia", "Cartesia", new[] { "CARTESIA_API_KEY" }),
- ("deepgram", "Deepgram", new[] { "DEEPGRAM_API_KEY" }),
- ("sherpaonnx", "SherpaOnnx (Offline)", Array.Empty() ),
- };
-
- foreach (var (id, name, requiredKeys) in engines)
- {
- var configured = requiredKeys.All(k => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(k)));
- var status = requiredKeys.Length == 0 ? "ready" : configured ? "configured" : "not configured";
- var color = configured ? ConsoleColor.Green : ConsoleColor.Gray;
- Console.ForegroundColor = color;
- Console.WriteLine($" {id,-15} {name,-35} [{status}]");
- Console.ResetColor();
- if (requiredKeys.Length > 0 && !configured)
- {
- Console.WriteLine($" Requires: {string.Join(", ", requiredKeys)}");
- }
- }
-
- Console.WriteLine();
- Console.WriteLine("Usage: EngineConfig.exe voices --engine --key [--region ]");
- return 0;
- }
-
- private static int ListVoices(string[] args)
- {
- var opts = ParseArgs(args);
- if (string.IsNullOrEmpty(opts.Engine))
- {
- Console.Error.WriteLine("Error: --engine is required");
- Console.Error.WriteLine("Usage: voices --engine --key [--region ]");
- return 1;
- }
-
- var creds = BuildCredentials(opts.Engine, opts.Key, opts.Region);
- if (creds == null)
- {
- Console.Error.WriteLine($"Error: Unknown engine '{opts.Engine}'");
- return 1;
- }
-
- Console.WriteLine($"Fetching voices for engine '{opts.Engine}'...");
-
- var client = DotNetTtsWrapper.Models.TtsFactory.CreateClient(opts.Engine, creds);
- if (client == null)
- {
- Console.Error.WriteLine($"Error: Could not create client for engine '{opts.Engine}'");
- return 1;
- }
-
- var voices = client.GetVoicesAsync().GetAwaiter().GetResult();
-
- if (opts.Json)
- {
- var json = JsonSerializer.Serialize(voices.Select(v => new
- {
- id = v.Id,
- name = v.Name,
- language = v.LanguageCodes?.FirstOrDefault()?.Bcp47 ?? "en-US",
- gender = v.Gender.ToString(),
- provider = v.Provider ?? opts.Engine
- }), new JsonSerializerOptions { WriteIndented = true });
- Console.WriteLine(json);
- }
- else
- {
- Console.WriteLine($"Found {voices.Count} voices:");
- Console.WriteLine();
- foreach (var voice in voices)
- {
- var lang = voice.LanguageCodes?.FirstOrDefault()?.Bcp47 ?? "en-US";
- Console.WriteLine($" {voice.Id,-40} {voice.Name,-30} {lang}");
- }
- }
-
- return 0;
- }
-
- private static int ValidateCredentials(string[] args)
- {
- var opts = ParseArgs(args);
- if (string.IsNullOrEmpty(opts.Engine))
- {
- Console.Error.WriteLine("Error: --engine is required");
- Console.Error.WriteLine("Usage: validate --engine --key [--region ]");
- return 1;
- }
-
- var creds = BuildCredentials(opts.Engine, opts.Key, opts.Region);
- if (creds == null)
- {
- Console.Error.WriteLine($"Error: Unknown engine '{opts.Engine}'");
- return 1;
- }
-
- Console.WriteLine($"Validating credentials for '{opts.Engine}'...");
-
- var client = DotNetTtsWrapper.Models.TtsFactory.CreateClient(opts.Engine, creds);
- if (client == null)
- {
- Console.Error.WriteLine($"Error: Could not create client for engine '{opts.Engine}'");
- return 1;
- }
-
- // Try CheckCredentialsAsync first (fast path)
- try
- {
- var result = client.CheckCredentialsAsync().GetAwaiter().GetResult();
- if (result.IsValid)
- {
- Console.ForegroundColor = ConsoleColor.Green;
- Console.WriteLine($" Credentials valid! ({result.AvailableVoiceCount} voices available)");
- Console.ResetColor();
- return 0;
- }
- else
- {
- Console.ForegroundColor = ConsoleColor.Red;
- Console.WriteLine($" Credentials invalid: {result.ErrorMessage}");
- Console.ResetColor();
- return 2;
- }
- }
- catch (Exception ex)
- {
- // CheckCredentialsAsync may not actually hit the API (hardcoded voice lists)
- // Fall through to real validation via a tiny synthesis attempt
- Console.WriteLine($" CheckCredentialsAsync inconclusive ({ex.Message}), trying synthesis...");
- }
-
- // Real validation: attempt a tiny synthesis
- try
- {
- var tempFile = Path.Combine(Path.GetTempPath(), $"validate_{opts.Engine}_{Guid.NewGuid():N}.wav");
- client.SynthToFileAsync("test", tempFile).GetAwaiter().GetResult();
- var file = new FileInfo(tempFile);
- if (file.Exists && file.Length > 0)
- {
- file.Delete();
- Console.ForegroundColor = ConsoleColor.Green;
- Console.WriteLine(" Credentials valid! (synthesis test succeeded)");
- Console.ResetColor();
- return 0;
- }
- else
- {
- Console.ForegroundColor = ConsoleColor.Red;
- Console.WriteLine(" Credentials invalid: synthesis produced no audio");
- Console.ResetColor();
- return 2;
- }
- }
- catch (HttpRequestException ex)
- {
- Console.ForegroundColor = ConsoleColor.Red;
- var status = ex.StatusCode?.ToString() ?? "unknown";
- Console.WriteLine($" Credentials invalid: HTTP {status}");
- if (status == "Unauthorized" || status == "Forbidden")
- Console.WriteLine(" API key is wrong, expired, or lacks permissions");
- Console.ResetColor();
- return 2;
- }
- catch (Exception ex)
- {
- Console.ForegroundColor = ConsoleColor.Red;
- Console.WriteLine($" Credentials invalid: {ex.Message}");
- Console.ResetColor();
- return 2;
- }
- }
-
- private static int PromoteVoice(string[] args)
- {
- var opts = ParseArgs(args);
-
- if (string.IsNullOrEmpty(opts.Engine) || string.IsNullOrEmpty(opts.VoiceId))
- {
- Console.Error.WriteLine("Error: --engine and --voice are required");
- Console.Error.WriteLine("Usage: promote --engine --voice --key [--region ]");
- return 1;
- }
-
- var creds = BuildCredentials(opts.Engine, opts.Key, opts.Region);
- if (creds == null)
- {
- Console.Error.WriteLine($"Error: Unknown engine '{opts.Engine}'");
- return 1;
- }
-
- var tokenName = $"Cloud-{opts.Engine}-{opts.VoiceId}".Replace("/", "_").Replace("\\", "_");
- var tokenPath = $@"{SapiTokensRoot}\{tokenName}";
-
- using var key = Microsoft.Win32.Registry.LocalMachine.CreateSubKey(tokenPath);
- if (key == null)
- {
- Console.Error.WriteLine("Error: Cannot create HKLM token (admin required)");
- return 1;
- }
-
- key.SetValue("", $"{opts.Engine} {opts.VoiceId}", Microsoft.Win32.RegistryValueKind.String);
- key.SetValue("CLSID", TtsEngineClsid, Microsoft.Win32.RegistryValueKind.String);
-
- using var configKey = key.CreateSubKey("VoiceGardenConfig");
- configKey.SetValue("EngineType", CapitalizeEngine(opts.Engine), Microsoft.Win32.RegistryValueKind.String);
- configKey.SetValue("Voice", opts.VoiceId, Microsoft.Win32.RegistryValueKind.String);
- configKey.SetValue("Key", opts.Key ?? "", Microsoft.Win32.RegistryValueKind.String);
- if (!string.IsNullOrEmpty(opts.Region))
- configKey.SetValue("Region", opts.Region, Microsoft.Win32.RegistryValueKind.String);
- configKey.SetValue("IsCloudVoice", 1, Microsoft.Win32.RegistryValueKind.DWord);
-
- // For Azure, also save key/region to the old registry location so the
- // C++ adapter's VoiceTokenEnumerator can enumerate Azure voices
- if (opts.Engine.Equals("azure", StringComparison.OrdinalIgnoreCase))
- {
- using var enumKey = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(
- @"SOFTWARE\VoiceGardenSAPIAdapter\Enumerator");
- if (enumKey != null && !string.IsNullOrEmpty(opts.Key))
- {
- enumKey.SetValue("AzureVoiceKey", opts.Key, Microsoft.Win32.RegistryValueKind.String);
- if (!string.IsNullOrEmpty(opts.Region))
- enumKey.SetValue("AzureVoiceRegion", opts.Region, Microsoft.Win32.RegistryValueKind.String);
- }
- }
-
- using var attrsKey = key.CreateSubKey("Attributes");
- attrsKey.SetValue("Name", opts.VoiceId, Microsoft.Win32.RegistryValueKind.String);
- attrsKey.SetValue("Gender", "Neutral", Microsoft.Win32.RegistryValueKind.String);
- attrsKey.SetValue("Age", "Adult", Microsoft.Win32.RegistryValueKind.String);
- attrsKey.SetValue("Language", "0409", Microsoft.Win32.RegistryValueKind.String);
- attrsKey.SetValue("Locale", opts.Locale ?? "en-US", Microsoft.Win32.RegistryValueKind.String);
- attrsKey.SetValue("Vendor", CapitalizeEngine(opts.Engine), Microsoft.Win32.RegistryValueKind.String);
- attrsKey.SetValue("VoiceGardenType", "Cloud", Microsoft.Win32.RegistryValueKind.String);
-
- Console.WriteLine($"Promoted {opts.Engine}/{opts.VoiceId} to HKLM token: {tokenName}");
- return 0;
- }
-
- private static int UnpromoteVoice(string[] args)
- {
- var opts = ParseArgs(args);
- if (string.IsNullOrEmpty(opts.VoiceId))
- {
- Console.Error.WriteLine("Error: --voice (token name) is required");
- return 1;
- }
-
- var tokenPath = $@"{SapiTokensRoot}\{opts.VoiceId}";
- try
- {
- Microsoft.Win32.Registry.LocalMachine.DeleteSubKeyTree(tokenPath);
- Console.WriteLine($"Removed HKLM token: {opts.VoiceId}");
- return 0;
- }
- catch
- {
- Console.Error.WriteLine($"Token not found: {opts.VoiceId}");
- return 1;
- }
- }
-
- private static int ListPromoted()
- {
- using var tokens = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(SapiTokensRoot);
- if (tokens == null)
- {
- Console.WriteLine("No promoted voices found.");
- return 0;
- }
-
- var cloudTokens = tokens.GetSubKeyNames()
- .Where(n => n.StartsWith("Cloud-", StringComparison.OrdinalIgnoreCase))
- .ToList();
-
- if (cloudTokens.Count == 0)
- {
- Console.WriteLine("No promoted cloud voices found.");
- return 0;
- }
-
- Console.WriteLine("Promoted Cloud Voices (HKLM):");
- Console.WriteLine();
- foreach (var name in cloudTokens)
- {
- using var tk = tokens.OpenSubKey(name);
- using var cfg = tk?.OpenSubKey("VoiceGardenConfig");
- var engine = cfg?.GetValue("EngineType") as string ?? "?";
- var voiceId = cfg?.GetValue("VoiceId") as string ?? "?";
- Console.WriteLine($" {name,-50} {engine,-15} {voiceId}");
- }
-
- return 0;
- }
-
- private static int TestVoice(string[] args)
- {
- var opts = ParseArgs(args);
-
- if (string.IsNullOrEmpty(opts.Engine) || string.IsNullOrEmpty(opts.VoiceId))
- {
- Console.Error.WriteLine("Error: --engine and --voice are required");
- Console.Error.WriteLine("Usage: test --engine --voice --key [--region ] --text \"Hello\"");
- return 1;
- }
-
- var creds = BuildCredentials(opts.Engine, opts.Key, opts.Region);
- if (creds == null)
- {
- Console.Error.WriteLine($"Error: Unknown engine '{opts.Engine}'");
- return 1;
- }
-
- var client = DotNetTtsWrapper.Models.TtsFactory.CreateClient(opts.Engine, creds);
- if (client == null)
- {
- Console.Error.WriteLine($"Error: Could not create client for engine '{opts.Engine}'");
- return 1;
- }
-
- client.SetVoice(opts.VoiceId);
-
- var text = opts.Text ?? "Hello world, this is a test.";
- var outputPath = opts.Output ?? Path.Combine(Path.GetTempPath(), "engineconfig_test.wav");
-
- Console.WriteLine($"Synthesizing '{text}' with {opts.Engine}/{opts.VoiceId}...");
-
- client.SynthToFileAsync(text, outputPath).GetAwaiter().GetResult();
-
- var file = new FileInfo(outputPath);
- if (file.Exists && file.Length > 0)
- {
- Console.WriteLine($"Audio saved: {outputPath} ({file.Length} bytes)");
- return 0;
- }
- else
- {
- Console.Error.WriteLine("Synthesis produced no audio");
- return 1;
- }
- }
-
- private static DotNetTtsWrapper.Models.ITtsCredentials? BuildCredentials(string engine, string? key, string? region)
- {
- return engine.ToLowerInvariant() switch
- {
- "azure" => new DotNetTtsWrapper.Models.AzureCredentials
- {
- SubscriptionKey = key ?? Environment.GetEnvironmentVariable("AZURE_SPEECH_KEY") ?? "",
- Region = region ?? Environment.GetEnvironmentVariable("AZURE_SPEECH_REGION") ?? "eastus"
- },
- "openai" => new DotNetTtsWrapper.Models.OpenAICredentials { ApiKey = key ?? "" },
- "elevenlabs" => new DotNetTtsWrapper.Models.ElevenLabsCredentials { ApiKey = key ?? "" },
- "google" => new DotNetTtsWrapper.Models.GoogleCredentials { ApiKey = key ?? "" },
- "polly" => new DotNetTtsWrapper.Models.PollyCredentials
- {
- AccessKeyId = key ?? Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID") ?? "",
- SecretAccessKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY") ?? "",
- Region = region ?? Environment.GetEnvironmentVariable("AWS_REGION") ?? "us-east-1"
- },
- "cartesia" => new DotNetTtsWrapper.Models.CartesiaCredentials { ApiKey = key ?? "" },
- "deepgram" => new DotNetTtsWrapper.Models.DeepgramCredentials { ApiKey = key ?? "" },
- "sherpaonnx" => new DotNetTtsWrapper.Models.SherpaOnnxCredentials(),
- _ => null
- };
- }
-
- private static string CapitalizeEngine(string engine) => engine.ToLowerInvariant() switch
- {
- "azure" => "Azure",
- "openai" => "OpenAI",
- "elevenlabs" => "ElevenLabs",
- "google" => "Google",
- "polly" => "Polly",
- "cartesia" => "Cartesia",
- "deepgram" => "DeepGram",
- "sherpaonnx" => "Sherpa",
- _ => engine
- };
-
- private static CliOptions ParseArgs(string[] args)
- {
- var opts = new CliOptions();
- for (int i = 1; i < args.Length; i++)
- {
- switch (args[i].ToLowerInvariant())
- {
- case "--engine" when i + 1 < args.Length:
- opts.Engine = args[++i]; break;
- case "--voice" when i + 1 < args.Length:
- opts.VoiceId = args[++i]; break;
- case "--key" when i + 1 < args.Length:
- opts.Key = args[++i]; break;
- case "--region" when i + 1 < args.Length:
- opts.Region = args[++i]; break;
- case "--locale" when i + 1 < args.Length:
- opts.Locale = args[++i]; break;
- case "--text" when i + 1 < args.Length:
- opts.Text = args[++i]; break;
- case "--output" when i + 1 < args.Length:
- opts.Output = args[++i]; break;
- case "--json":
- opts.Json = true; break;
- }
- }
- return opts;
- }
-
- private static int ShowHelp()
- {
- Console.WriteLine();
- Console.WriteLine("EngineConfig - Multi-Engine TTS Voice Manager");
- Console.WriteLine("=============================================");
- Console.WriteLine();
- Console.WriteLine("Usage:");
- Console.WriteLine(" EngineConfig.exe [options]");
- Console.WriteLine();
- Console.WriteLine("Commands:");
- Console.WriteLine(" engines List supported engines and config status");
- Console.WriteLine(" voices --engine List voices for an engine");
- Console.WriteLine(" --key API key for the engine");
- Console.WriteLine(" [--region ] Region (Azure/Polly)");
- Console.WriteLine(" [--json] Output as JSON");
- Console.WriteLine();
- Console.WriteLine(" promote --engine Register a voice as HKLM SAPI token");
- Console.WriteLine(" --voice Voice ID to promote");
- Console.WriteLine(" --key API key");
- Console.WriteLine(" [--region ] Region");
- Console.WriteLine(" [--locale ] BCP-47 locale (default: en-US)");
- Console.WriteLine();
- Console.WriteLine(" unpromote --voice Remove a promoted voice from HKLM");
- Console.WriteLine();
- Console.WriteLine(" promoted List all promoted cloud voices");
- Console.WriteLine();
- Console.WriteLine(" test --engine Test synthesis with an engine");
- Console.WriteLine(" --voice Voice to use");
- Console.WriteLine(" --key API key");
- Console.WriteLine(" [--text \"Hello world\"] Text to synthesize");
- Console.WriteLine(" [--output ] Output WAV file");
- Console.WriteLine();
- Console.WriteLine("Supported Engines:");
- Console.WriteLine(" azure, openai, elevenlabs, google, polly, cartesia, deepgram, sherpaonnx");
- Console.WriteLine();
- Console.WriteLine("Examples:");
- Console.WriteLine(" EngineConfig.exe engines");
- Console.WriteLine(" EngineConfig.exe voices --engine azure --key YOUR_KEY --region eastus");
- Console.WriteLine(" EngineConfig.exe voices --engine openai --key sk-xxx --json");
- Console.WriteLine(" EngineConfig.exe promote --engine azure --voice en-US-JennyNeural --key YOUR_KEY --region eastus");
- Console.WriteLine(" EngineConfig.exe test --engine openai --voice alloy --key sk-xxx --text \"Hello\"");
- Console.WriteLine();
- return 0;
- }
-
- private static int UnknownCommand(string command)
- {
- Console.Error.WriteLine($"Unknown command: '{command}'");
- Console.Error.WriteLine();
- ShowHelp();
- return 1;
- }
-
- private class CliOptions
- {
- public string? Engine { get; set; }
- public string? VoiceId { get; set; }
- public string? Key { get; set; }
- public string? Region { get; set; }
- public string? Locale { get; set; }
- public string? Text { get; set; }
- public string? Output { get; set; }
- public bool Json { get; set; }
- }
-}
diff --git a/README.md b/README.md
index d1e8753..846caff 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,32 @@
# VoiceGarden-SAPI
-> This project was forked from [NaturalVoiceSAPIAdapter](https://github.com/gexgd0419/NaturalVoiceSAPIAdapter) and is now developed at [AACTools/VoiceGarden-SAPI](https://github.com/AACTools/VoiceGarden-SAPI).
+> Forked from [NaturalVoiceSAPIAdapter](https://github.com/gexgd0419/NaturalVoiceSAPIAdapter). Developed at [AACTools/VoiceGarden-SAPI](https://github.com/AACTools/VoiceGarden-SAPI).
-A [SAPI 5 text-to-speech (TTS) engine][1] that connects **20+ cloud TTS engines** and **offline SherpaOnnx models** to any Windows application that supports SAPI voices — including Grid 3, The Grid, Clicker, and any software using `System.Speech`.
+A [SAPI 5 text-to-speech engine][1] that connects **21+ TTS engines** to any Windows application that supports SAPI voices — including Grid 3, Mind Express, Balabolka, Clicker, and any software using `System.Speech`.
+
+Powered by [rust-tts-wrapper](https://github.com/AACTools/rust-tts-wrapper) for all synthesis, voice listing, and word boundary events.
## What voices are supported?
| Category | Engines | Cloud? |
|----------|---------|--------|
-| **Offline neural** | SherpaOnnx (Kokoro, Piper, MMS, VITS, Matcha) | No — fully local |
-| **Microsoft** | Azure Cognitive Services, Edge browser voices | Yes |
+| **Offline neural** | SherpaOnnx (Kokoro, Piper, MMS, VITS, Matcha, Kitten) | No — fully local |
+| **Microsoft** | Azure Cognitive Services, Edge browser voices (credential-free) | Yes |
| **Cloud TTS** | OpenAI, Google Cloud, AWS Polly, ElevenLabs, Cartesia, Deepgram | Yes |
| **More cloud** | Watson, PlayHT, Wit.ai, Gemini, Hume AI, xAI Grok, Fish Audio, Mistral, Murf, Unreal Speech, Resemble, Uplift AI, Models Lab | Yes |
-Any SAPI 5-compatible program can use these voices. Offline SherpaOnnx voices require no internet connection.
+All engines support **word boundary events** for word highlighting in AAC software.
## Quick Start
### Install
-1. Download the MSI from the [Releases][6] section.
-2. Run `setup.exe` (or the `.msi` directly).
-3. After installation, **VoiceGarden.UI.exe** launches automatically — this is the configuration app.
+1. Download `setup.exe` from the [Releases][6] section.
+2. Run `setup.exe` — it installs the MSI and **auto-registers** the SAPI adapter.
+3. After installation, **VoiceGarden.UI.exe** launches automatically.
4. Go to the **SherpaOnnx** tab to download offline models, or the **Engine Config** tab to enter cloud API keys.
5. Click **Install to SAPI** to promote voices to the Windows registry.
6. Restart your SAPI application (e.g., Grid 3) — the new voices appear in the voice list.
-### Register the adapter (if running from a build)
-```
-VoiceGarden.UI.exe install # registers 64-bit DLL
-VoiceGarden.UI.exe install32 # registers 32-bit DLL (for 32-bit apps)
-```
-
### CLI mode
VoiceGarden.UI.exe doubles as a command-line tool:
@@ -46,23 +42,33 @@ VoiceGarden.UI.exe validate --engine azure --voice en-US-JennyNeural --key KEY -
## System Requirements
-- **OS:** Windows 7 SP1 or later (32-bit and 64-bit supported)
+- **OS:** Windows 7 SP1 or later (64-bit recommended; 32-bit supported with limitations)
- **Runtime:** .NET 8.0 desktop runtime (for VoiceGarden.UI)
-- **For SherpaOnnx extraction:** [7-Zip](https://www.7-zip.org/) (for `.tar.bz2` model archives)
- **For cloud voices:** Internet access + valid API key for the respective service
## Architecture
```
┌──────────────────────────────────────────────────────────┐
-│ SAPI Application (Grid 3, System.Speech, etc.) │
+│ SAPI Application (Grid 3, Mind Express, Balabolka) │
│ │ SAPI 5 COM │
│ ┌─────────────────▼──────────────────────────────────┐ │
│ │ VoiceGardenSAPIAdapter.dll (C++ COM DLL) │ │
-│ │ ┌──────────────┐ ┌────────────┐ ┌────────────┐ │ │
-│ │ │ SherpaOnnx │ │ Azure REST │ │GenericHttp │ │ │
-│ │ │ (offline) │ │ + SDK shim │ │(OpenAI etc)│ │ │
-│ │ └──────────────┘ └────────────┘ └────────────┘ │ │
+│ │ • BuildSSML (SAPI fragments → SSML) │ │
+│ │ • Word boundary offset mapping │ │
+│ │ • Audio streaming + silence compensation │ │
+│ │ │ loads via LoadLibrary │ │
+│ │ ┌────────▼─────────────────────────────────────┐ │ │
+│ │ │ rust_tts_wrapper.dll (Rust, 22MB) │ │ │
+│ │ │ • 21 engines (SherpaOnnx, Azure, Edge, │ │ │
+│ │ │ OpenAI, Google, ElevenLabs, Polly, ...) │ │ │
+│ │ │ • Word boundary events (Azure/Google: real, │ │ │
+│ │ │ others: estimated) │ │ │
+│ │ │ • Viseme events (Azure/Edge) │ │ │
+│ │ │ • Connection pooling (Azure/Edge WS) │ │ │
+│ │ │ • Sec-MS-GEC token (Edge voices) │ │ │
+│ │ │ • SherpaOnnx model auto-detection │ │ │
+│ │ └──────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
@@ -70,7 +76,8 @@ VoiceGarden.UI.exe validate --engine azure --voice en-US-JennyNeural --key KEY -
│ │ • Download/promote SherpaOnnx models │ │
│ │ • Configure cloud engine credentials │ │
│ │ • Register/unregister 32-bit + 64-bit DLLs │ │
-│ │ • Preview voices │ │
+│ │ • Preview voices (via RustTtsWrapper.Bindings) │ │
+│ │ • Anonymous usage analytics (opt-in, PostHog EU) │ │
│ └─────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
@@ -79,16 +86,15 @@ VoiceGarden.UI.exe validate --engine azure --voice en-US-JennyNeural --key KEY -
| Component | Description |
|-----------|-------------|
-| `VoiceGardenSAPIAdapter/` | C++ SAPI COM DLL — the actual TTS engine. Handles SherpaOnnx (offline), Azure REST/SDK, and generic HTTP TTS |
-| `VoiceGarden.UI/` | Avalonia UI app — main configuration tool. Replaces the old C++ Installer |
-| `VoiceGardenSAPIAdapter.Net/` | .NET SAPI adapter (alternative runtime, currently inactive) |
-| `SherpaOnnxConfig/` | CLI tool for SherpaOnnx model management |
-| `EngineConfig/` | CLI tool for cloud engine voice management |
+| `VoiceGardenSAPIAdapter/` | C++ SAPI COM DLL — SSML building, offset mapping, audio streaming. Loads `rust_tts_wrapper.dll` for all synthesis |
+| `VoiceGardenSAPIAdapter/RustTts/` | C++ wrapper for the Rust DLL (dynamic loading, callback marshalling) |
+| `VoiceGarden.UI/` | Avalonia UI app — configuration, model management, voice preview, analytics |
+| `SherpaOnnx/` | Model discovery (voice enumerator scans for installed models) |
| `Setup/` + `SetupLauncher/` | WiX MSI package + setup.exe bootstrapper |
## SherpaOnnx Offline Voices
-SherpaOnnx provides fully offline neural TTS — no internet, no API keys, no cloud dependency.
+Fully offline neural TTS — no internet, no API keys, no cloud dependency.
### Supported model types
- **Kokoro** — high-quality English models with multiple voices (`voices.bin`)
@@ -99,17 +105,11 @@ SherpaOnnx provides fully offline neural TTS — no internet, no API keys, no cl
### Downloading models
Models are stored in `%LOCALAPPDATA%\VoiceGardenSAPIAdapter\models\`. Download via:
-- **UI:** SherpaOnnx tab → select models → Download Selected
+- **UI:** SherpaOnnx tab → select models → Download Selected (built-in extraction, no 7-Zip needed)
- **CLI:** `VoiceGarden.UI.exe models download kokoro-en-en-19`
-### Model type detection
-The system auto-detects model type from directory contents:
-- `voices.bin` present → Kokoro (type 2)
-- `vocoder.onnx` present → Matcha (type 1)
-- Otherwise → VITS (type 0)
-
### Grid 3 compatibility
-Grid 3 (`System.Speech`) only selects voices from HKLM registry tokens — in-memory enumerator voices won't work with `SelectVoice`. Use **Install to SAPI** to promote voices to HKLM.
+Grid 3 (`System.Speech`) only selects voices from HKLM registry tokens. Use **Install to SAPI** to promote voices to HKLM. Word boundary events work with Grid3 (tested, no crash).
See `docs/troubleshooting-grid3-voice-activation.md` for detailed Grid 3 troubleshooting.
@@ -118,26 +118,29 @@ See `docs/troubleshooting-grid3-voice-activation.md` for detailed Grid 3 trouble
### Azure Cognitive Services
1. Get a key from [Azure Portal](https://portal.azure.com/) → Speech service → Keys and Endpoint
2. In VoiceGarden.UI → Engine Config → Azure: enter key and region (e.g., `uksouth`)
-3. Promote voices to SAPI
+3. Fetch voices → select → Install Selected
-### Google Cloud TTS
-1. Get an API key from [Google Cloud Console](https://console.cloud.google.com/) → APIs & Services → Credentials
-2. In VoiceGarden.UI → Engine Config → Google: enter key
-3. Promote a specific voice (e.g., `en-US-Wavenet-D`)
+### Edge browser voices
+Free, credential-free voices from Microsoft Edge's Read Aloud feature. Enable in Advanced settings → "Enable Edge browser voices". The Rust engine computes the Sec-MS-GEC token automatically.
-### Other engines (OpenAI, ElevenLabs, Polly, etc.)
-Each cloud engine needs its API key set in the Engine Config tab. The C++ adapter handles REST calls via `GenericHttpTts` for OpenAI, ElevenLabs, Google, Cartesia, and Deepgram. Azure and Edge voices use the dedicated REST/WebSocket path.
+### Other engines (OpenAI, Google, ElevenLabs, etc.)
+Each cloud engine needs its API key set in the Engine Config tab. Search voices by language name (e.g., "arabic", "gujarati") or voice ID.
## Testing
-### End-to-end test script
+### Boundary crash test (Grid3 pattern)
```powershell
-.\scripts\test-sherpa-e2e.ps1 # downloads models, promotes, speaks via System.Speech
+.\scripts\test-boundary-crash.ps1 # PromptBuilder with rate changes — reproduces Grid3 crash
+```
+
+### Word boundary events
+```powershell
+.\scripts\test-word-boundaries.ps1 # checks word text + position for each voice
```
### Cleanup (remove all custom voices)
```powershell
-.\scripts\cleanup-voices.ps1 # removes Sherpa/Cloud/eSpeak tokens
+.\scripts\cleanup-voices.ps1 # removes Sherpa/Cloud/Edge tokens from HKLM + HKCU
.\scripts\cleanup-voices.ps1 -DryRun # preview without deleting
```
@@ -154,47 +157,41 @@ $s.Speak("Hello, this is a test.")
### Prerequisites
- Visual Studio 2022 with C++ (MSVC v143)
- .NET 8.0 SDK
-- [WiX Toolset v3](https://wixtoolset.org/) (for MSI)
-- [7-Zip](https://www.7-zip.org/) (for SherpaOnnx deps extraction)
+- [WiX Toolset v4](https://wixtoolset.org/) (for MSI)
### Local build
```powershell
-.\download-sherpa-deps.ps1 -Platforms x64,x86 # one-time: download SherpaOnnx native DLLs
-.\scripts\build-release-local.ps1 -Configuration Release -Platforms x64,x86 -BuildSetup -SkipSherpaDeps -SkipSubmodules
+.\download-sherpa-deps.ps1 -Platforms x64 # one-time: download SherpaOnnx native DLLs
+.\scripts\build-release-local.ps1 -Configuration Release -Platforms x64 -BuildSetup -SkipSherpaDeps -SkipSubmodules
```
Output: `installer-output\VoiceGardenSAPIAdapter.msi`
### CI
GitHub Actions workflow at `.github/workflows/msbuild.yml` builds all components on every push:
-- C++ adapter DLL (x86, x64, ARM64)
-- VoiceGarden.UI (Avalonia app)
+- C++ adapter DLL (x64 + x86)
+- VoiceGarden.UI (Avalonia app with RustTtsWrapper.Bindings)
- SherpaOnnxConfig + EngineConfig CLI tools
- .NET adapter
-- MSI setup package
+- MSI setup package (auto-registers DLL on install)
-## Configurable Registry Values
+## Privacy
-See the [wiki page on configurable registry values][8] for advanced settings including:
-- `NoEdgeVoices`, `NoAzureVoices`, `NoSherpaVoices` — toggle voice categories
-- `AzureVoiceKey`, `AzureVoiceRegion` — Azure credentials
-- `EdgeVoiceLanguages` — filter Edge voices by language
-- `ErrorMode` — control error handling behavior
+VoiceGarden.UI includes optional, anonymous usage analytics (PostHog, EU-hosted). Disabled by default. See `docs/PRIVACY.md` for full details.
## Libraries Used
-- [SherpaOnnx](https://github.com/k2-fsa/sherpa-onnx) — offline neural TTS
-- [DotNetTtsWrapper](https://github.com/AACTools/dotnet-tts-wrapper) — .NET TTS client for 20+ engines
-- [Avalonia UI](https://avaloniaui.net/) — cross-platform .NET UI framework
+**Primary:**
+- [rust-tts-wrapper](https://github.com/AACTools/rust-tts-wrapper) — Rust TTS engine for all synthesis (21 engines, streaming, word boundaries)
+- [RustTtsWrapper.Bindings](https://www.nuget.org/packages/RustTtsWrapper.Bindings) — .NET P/Invoke bindings for the Rust DLL
+- [Avalonia UI](https://avaloniaui.net/) — .NET desktop UI framework
- [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) — MVVM framework
-- Microsoft.CognitiveServices.Speech — Azure Speech SDK
-- [websocketpp](https://github.com/zaphoyd/websocketpp) — WebSocket client (Edge voices)
-- OpenSSL — HTTPS for cloud TTS
-- [nlohmann/json](https://github.com/nlohmann/json) — JSON parsing
-- [spdlog](https://github.com/gabime/spdlog) — logging
-- [YY-Thunks](https://github.com/Chuyu-Team/YY-Thunks) — Windows XP compatibility
-- [Detours](https://github.com/microsoft/Detours) — API hooking
+- [SharpCompress](https://github.com/adamhathcock/sharpcompress) — built-in model archive extraction
+
+**C++ adapter dependencies:**
+- [SherpaOnnx](https://github.com/k2-fsa/sherpa-onnx) — offline neural TTS native runtime
+- Microsoft Azure Speech SDK — embedded TTS (DLL shim)
+- OpenSSL, websocketpp, nlohmann/json, spdlog — legacy C++ networking (used by Azure SDK)
[1]: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ms717037(v=vs.85)
[6]: ../../releases
-[8]: ../../wiki/Configurable-registry-values
diff --git a/README.zh.md b/README.zh.md
deleted file mode 100644
index 272df49..0000000
--- a/README.zh.md
+++ /dev/null
@@ -1,104 +0,0 @@
-# VoiceGarden-SAPI
-
-[For the English version, click here](README.md)
-
-> **注意:** 本项目最初 fork 自 [VoiceGardenSAPIAdapter](https://github.com/gexgd0419/VoiceGardenSAPIAdapter),现已迁移至 [AACTools/VoiceGarden-SAPI](https://github.com/AACTools/VoiceGarden-SAPI) 作为独立项目。
->
-> 本 fork 与上游项目有以下重要差异:
-> - **内置 SherpaOnnx 支持**,可使用 SherpaOnnx 模型进行完全本地的离线 TTS,无需云服务。
-> - 我们担心通过 SAPI 使用 Edge 和讲述人语音可能违反微软的服务条款。虽然上游项目的语音定义仍通过 JSON 配置文件保留在我们的代码库中,但我们认为发布依赖这些语音的产品存在风险。VoiceGarden-SAPI 专注于合法的语音来源——主要是 SherpaOnnx 本地模型和具有有效订阅密钥的 Azure 语音。
-
-连接 [Azure AI 语音服务][3],使第三方程序也能使用微软[自然语音][2]的 [SAPI 5 TTS 引擎][1]。支持如下自然语音:
-
-- Windows 11 中的讲述人自然语音
-- Microsoft Edge 中“大声朗读”功能的在线自然语音
-- 来自 Azure AI 语音服务的在线自然语音,只要你有对应的 key
-
-任何支持 SAPI 5 语音的程序都可以借助此引擎使用上述的自然语音。
-
-更多技术相关的信息可以参阅 [wiki 页面][4]。
-
-## 系统要求
-
-最低的测试可用的系统版本:Windows XP SP3,Windows XP Professional x64 Edition SP2 (仅32位)
-
-最低的支持本地讲述人语音的系统版本:Windows 7 RTM,x86 32/64位
-
-最低的支持通过微软商店安装讲述人语音的系统版本:Windows 10 17763
-
-### 如何在 Windows 11 上安装讲述人自然语音?
-
-目前不再推荐在 Windows 11 上安装讲述人自然语音,因为最新版本的语音包不能在该程序中使用。建议下载[最后一个可使用的语音版本][5],并使用该版本。
-
-如果安装该程序后讲述人不能使用,作为临时措施,可以尝试卸载所有安装的讲述人语音包。
-
-### 我用的 Windows XP/Vista/7/8/10 系统,可以使用 Windows 11 系统的讲述人自然语音吗?
-
-**Windows XP/Vista**: 很遗憾,这些平台并不支持本地讲述人自然语音。不过 Edge 和 Azure 的在线语音依然能用。
-
-**Windows 10 (版本 17763 及以上)**: 可以使用[这里的链接][5]下载安装 Windows 11 系统的讲述人自然语音。
-
-**Windows 7/8/10 (版本 17763 之前)**:
-1. 在[这里][5]下载语音的 MSIX 文件。
-2. 准备一个文件夹用于存放语音文件夹,确保其路径内没有非 ASCII 字符(如汉字和中文符号)。
-3. 将下载的 MSIX 文件作为 ZIP 压缩包解压至其子文件夹内。可以在相同的父文件夹下放置多个语音的子文件夹,确保子文件夹的名称内没有非 ASCII 字符。
-4. 在安装程序中,将父文件夹设为“本地语音路径”。
-5. 之后不要在此父文件夹下存放语音文件夹以外的内容,以免语音加载失败。
-
-Windows 10 系统的讲述人并不支持自然语音,但是支持 SAPI 5 语音,所以可以借助本引擎间接让 Windows 10 系统的讲述人也支持自然语音。
-
-### 之后的 Windows 版本还能用吗?
-
-本引擎使用了从系统文件中提取的解密密钥来使用语音,这并不是官方认可的行为。
-
-目前微软并没有允许第三方程序使用讲述人语音和 Edge 语音。因此,本引擎随时可能会在某次系统更新后停止工作。
-
-## 安装
-
-1. 从 [Releases][6] 栏下载 zip 文件。
-2. 解压至一个文件夹。安装完成后,不要再移动、重命名或删除这些文件。若需要移动或删除文件,应先卸载。
-3. 运行 `Installer.exe`。
-4. 界面会在“安装状态”分区显示 32 位和 64 位版本是否已经安装。
- - 32 位版本用于 32 位程序,64 位版本用于 64 位程序。
- - 64 位系统中,若希望所有程序(包括 32 位和 64 位程序)都能使用语音,则两个版本都要安装。
- - 32 位系统中,“64 位”一行不会显示。
-5. 单击安装/卸载。需要管理员权限。
-6. 可以选择需要使用的语音类型。默认情况下,SherpaOnnx 本地模型(若可用)以及 Azure 在线语音处于启用状态。
- - **推荐:** 使用 SherpaOnnx 本地模型进行完全离线的 TTS,无需云服务依赖。
- - 在线语音要求互联网连接,且可能更慢、更不稳定。如果只需要使用本地语音,可以取消勾选“启用 Microsoft Edge 在线语音”和“启用 Azure 在线语音”。
- - 由于在线语音数量众多,默认只包含符合你的偏好语言的语音和英语(美国)的语音,以免语音列表出现过多项目。单击“更改...”按钮以更改包含的语言。
- - Azure 语音要求提供订阅密钥 (API key) 及其区域。可以访问 [Azure 门户](https://portal.azure.com/),转到你的语音服务资源,之后转到 **资源管理** > **密钥和终结点**,复制粘贴密钥和区域。
-7. 关闭安装程序窗口以应用更改。若之后想更改设置,可以再次打开安装程序。更改设置无需重新安装,无需管理员权限。
-
-
-
-
-也可以用 `regsvr32` 手动注册 DLL 文件。
-
-对于高级用户,这里有本程序的[可配置的注册表值][8]的列表。
-
-## 测试
-
-可以使用 `x86` 和 `x64` 文件夹中的 `TtsApplication.exe` 来测试本引擎。
-
-这个程序修改自 [Windows-classic-samples 中的 TtsApplication][7],添加了中文翻译和更详细的语素口型事件信息。
-
-或者,可以转到 控制面板 > 语音 (Windows XP),或 控制面板 > 语音识别 > 文本到语音转换 (Windows Vista 及以后)。
-
-## 使用的库
-- Microsoft.CognitiveServices.Speech.Extension.Embedded.TTS
-- [websocketpp](https://github.com/zaphoyd/websocketpp)
-- ASIO (独立版本)
-- OpenSSL
-- [nlohmann/json](https://github.com/nlohmann/json)
-- [YY-Thunks](https://github.com/Chuyu-Team/YY-Thunks) (用于实现 Windows XP 兼容)
-- [spdlog](https://github.com/gabime/spdlog)
-
-[1]: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ms717037(v=vs.85)
-[2]: https://speech.microsoft.com/portal/voicegallery
-[3]: https://learn.microsoft.com/azure/ai-services/speech-service/
-[4]: ../../wiki
-[5]: ../../wiki/讲述人自然语音下载链接
-[6]: ../../releases
-[7]: https://github.com/microsoft/Windows-classic-samples/tree/main/Samples/Win7Samples/winui/speech/ttsapplication
-[8]: ../../wiki/Configurable-registry-values
diff --git a/Setup/Package.wxs b/Setup/Package.wxs
index 9a18e36..f2d19d8 100644
--- a/Setup/Package.wxs
+++ b/Setup/Package.wxs
@@ -30,8 +30,28 @@
Impersonate="yes"
Return="ignore" />
+
+
+
+
+
+
+
+
diff --git a/Setup/Setup.wixproj b/Setup/Setup.wixproj
index 3d0dd31..534bfbe 100644
--- a/Setup/Setup.wixproj
+++ b/Setup/Setup.wixproj
@@ -1,7 +1,7 @@
Package
- 0.3.0.0
+ 0.4.0.0
$(MSBuildProjectDirectory)\..\out
VoiceGardenSAPIAdapter
VoiceGardenSAPIAdapter
diff --git a/SetupLauncher/Program.cs b/SetupLauncher/Program.cs
index aaff28d..12e0a9d 100644
--- a/SetupLauncher/Program.cs
+++ b/SetupLauncher/Program.cs
@@ -1,24 +1,21 @@
using Microsoft.Win32;
using System.Diagnostics;
using System.Text.RegularExpressions;
-using System.Text.Json;
using System.Windows.Forms;
static class Program
{
sealed class Branding
{
- public string ProductName { get; set; } = "VoiceGardenSAPIAdapter";
- public string SetupCaption { get; set; } = "VoiceGardenSAPIAdapter Setup";
- public string InstallFolderName { get; set; } = "VoiceGardenSAPIAdapter";
+ public const string ProductName = "VoiceGardenSAPI";
+ public const string SetupCaption = "VoiceGardenSAPI Setup";
+ public const string InstallFolderName = "VoiceGardenSAPI";
}
static int Main(string[] args)
{
try
{
- Branding branding = LoadBranding(AppContext.BaseDirectory);
-
bool uninstall = HasArg(args, "--uninstall") || HasArg(args, "/uninstall");
bool quiet = HasArg(args, "--silent") || HasArg(args, "/silent") || HasArg(args, "/quiet");
bool removeAppData = HasArg(args, "--remove-appdata");
@@ -28,17 +25,17 @@ static int Main(string[] args)
if (!File.Exists(msiPath))
{
- Notify($"MSI not found next to setup.exe:\n{msiPath}", branding.SetupCaption, quiet, error: true);
+ Notify($"MSI not found next to setup.exe:\n{msiPath}", Branding.SetupCaption, quiet, error: true);
return 2;
}
string msiexecArgs;
if (uninstall)
{
- string? productCode = FindInstalledProductCode(branding.ProductName);
+ string? productCode = FindInstalledProductCode(Branding.ProductName);
if (string.IsNullOrWhiteSpace(productCode))
{
- Notify($"Could not find an installed {branding.ProductName} product.", branding.SetupCaption, quiet, error: true);
+ Notify($"Could not find an installed {Branding.ProductName} product.", Branding.SetupCaption, quiet, error: true);
return 3;
}
@@ -66,24 +63,24 @@ static int Main(string[] args)
{
if (uninstall)
{
- Notify("Uninstall completed.", branding.SetupCaption, quiet, error: false);
+ Notify("Uninstall completed.", Branding.SetupCaption, quiet, error: false);
}
else
{
- if (!TryLaunchInstalledInstaller(branding))
+ if (!TryLaunchInstalledInstaller())
{
- Notify("Install completed, but VoiceGarden.UI.exe was not found in the install location.", branding.SetupCaption, quiet, error: true);
+ Notify("Install completed, but VoiceGarden.UI.exe was not found in the install location.", Branding.SetupCaption, quiet, error: true);
return 4;
}
}
}
else if (rc == 3010)
{
- Notify("Operation completed. A reboot is required.", branding.SetupCaption, quiet, error: false);
+ Notify("Operation completed. A reboot is required.", Branding.SetupCaption, quiet, error: false);
}
else if (rc == 1602)
{
- Notify("Installation was cancelled.", branding.SetupCaption, quiet, error: false);
+ Notify("Installation was cancelled.", Branding.SetupCaption, quiet, error: false);
}
else if (rc == 1625)
{
@@ -91,11 +88,11 @@ static int Main(string[] args)
"This can happen if Windows Installer is restricted by your administrator.\n" +
"Try right-clicking setup.exe → 'Run as administrator'.\n\n" +
"On managed devices (e.g., Grid Pad), you may need to ask your " +
- "administrator to allow MSI installations.", branding.SetupCaption, quiet, error: true);
+ "administrator to allow MSI installations.", Branding.SetupCaption, quiet, error: true);
}
else
{
- Notify($"Setup failed with exit code {rc}.", branding.SetupCaption, quiet, error: true);
+ Notify($"Setup failed with exit code {rc}.", Branding.SetupCaption, quiet, error: true);
}
}
return rc;
@@ -107,32 +104,6 @@ static int Main(string[] args)
}
}
- static Branding LoadBranding(string baseDir)
- {
- try
- {
- string p = Path.Combine(baseDir, "branding.json");
- if (!File.Exists(p))
- return new Branding();
-
- using FileStream fs = File.OpenRead(p);
- using JsonDocument doc = JsonDocument.Parse(fs);
- Branding b = new Branding();
- JsonElement root = doc.RootElement;
- if (root.TryGetProperty("product_name", out JsonElement product) && product.ValueKind == JsonValueKind.String)
- b.ProductName = product.GetString() ?? b.ProductName;
- if (root.TryGetProperty("app_caption", out JsonElement caption) && caption.ValueKind == JsonValueKind.String)
- b.SetupCaption = caption.GetString() ?? b.SetupCaption;
- if (root.TryGetProperty("install_folder_name", out JsonElement folder) && folder.ValueKind == JsonValueKind.String)
- b.InstallFolderName = folder.GetString() ?? b.InstallFolderName;
- return b;
- }
- catch
- {
- return new Branding();
- }
- }
-
static bool HasArg(string[] args, string needle) =>
args.Any(a => string.Equals(a, needle, StringComparison.OrdinalIgnoreCase));
@@ -196,12 +167,12 @@ static int RunMsiexec(string arguments)
static bool IsProductCode(string value) =>
Regex.IsMatch(value, @"^\{[0-9A-Fa-f\-]{36}\}$");
- static bool TryLaunchInstalledInstaller(Branding branding)
+ static bool TryLaunchInstalledInstaller()
{
var candidates = new[]
{
- Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), branding.InstallFolderName, "VoiceGarden.UI.exe"),
- Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), branding.InstallFolderName, "VoiceGarden.UI.exe"),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), Branding.InstallFolderName, "VoiceGarden.UI.exe"),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), Branding.InstallFolderName, "VoiceGarden.UI.exe"),
};
foreach (string p in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
diff --git a/SherpaOnnx/SherpaOnnxDynamic.cpp b/SherpaOnnx/SherpaOnnxDynamic.cpp
deleted file mode 100644
index 010471b..0000000
--- a/SherpaOnnx/SherpaOnnxDynamic.cpp
+++ /dev/null
@@ -1,205 +0,0 @@
-#include "SherpaOnnxDynamic.h"
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-namespace SherpaOnnx {
-namespace Dynamic {
-namespace {
-std::mutex g_loaderInitMutex;
-
-std::string GetCurrentModuleDirectory()
-{
- HMODULE module = nullptr;
- if (!GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
- GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
- reinterpret_cast(&GetCurrentModuleDirectory),
- &module)) {
- return {};
- }
-
- std::array path{};
- DWORD len = GetModuleFileNameA(module, path.data(), static_cast(path.size()));
- if (len == 0 || len >= path.size()) {
- return {};
- }
-
- std::filesystem::path p(path.data());
- return p.parent_path().string();
-}
-
-std::string GetModulePath(HMODULE module)
-{
- if (!module) {
- return {};
- }
-
- std::array path{};
- DWORD len = GetModuleFileNameA(module, path.data(), static_cast(path.size()));
- if (len == 0 || len >= path.size()) {
- return {};
- }
- return std::string(path.data(), len);
-}
-
-std::string ToHexError(DWORD err)
-{
- std::ostringstream oss;
- oss << "0x" << std::hex << err;
- return oss.str();
-}
-
-void AppendLoaderLogTo(const std::filesystem::path& filePath, const std::string& line)
-{
- std::error_code ec;
- std::filesystem::create_directories(filePath.parent_path(), ec);
- std::ofstream out(filePath.string(), std::ios::app);
- if (!out.is_open()) {
- return;
- }
- out << line << "\n";
-}
-
-void AppendLoaderLog(const std::string& line)
-{
- char appData[MAX_PATH] = {};
- DWORD len = GetEnvironmentVariableA("LOCALAPPDATA", appData, MAX_PATH);
- if (len != 0 && len < MAX_PATH) {
- std::filesystem::path logPath(appData);
- logPath /= "VoiceGardenSAPIAdapter";
- logPath /= "sherpa_loader.log";
- AppendLoaderLogTo(logPath, line);
- }
-
- std::string moduleDir = GetCurrentModuleDirectory();
- if (!moduleDir.empty()) {
- AppendLoaderLogTo(std::filesystem::path(moduleDir) / "sherpa_loader.log", line);
- }
-
- char tempPath[MAX_PATH] = {};
- DWORD tempLen = GetTempPathA(MAX_PATH, tempPath);
- if (tempLen != 0 && tempLen < MAX_PATH) {
- AppendLoaderLogTo(std::filesystem::path(tempPath) / "VoiceGardenSAPIAdapter-sherpa_loader.log", line);
- }
-}
-}
-
-SherpaOnnxLoader::SherpaOnnxLoader()
-{
- // Initialize all function pointers to null
- SherpaOnnxCreateOfflineTts = nullptr;
- SherpaOnnxDestroyOfflineTts = nullptr;
- SherpaOnnxOfflineTtsGenerate = nullptr;
- SherpaOnnxOfflineTtsGenerateWithProgressCallbackWithArg = nullptr;
- SherpaOnnxDestroyOfflineTtsGeneratedAudio = nullptr;
- SherpaOnnxOfflineTtsSampleRate = nullptr;
- SherpaOnnxOfflineTtsNumSpeakers = nullptr;
-}
-
-SherpaOnnxLoader::~SherpaOnnxLoader()
-{
- // Free the DLL module
- if (m_hModule) {
- FreeLibrary(m_hModule);
- m_hModule = nullptr;
- }
-}
-
-SherpaOnnxLoader& SherpaOnnxLoader::Instance()
-{
- static SherpaOnnxLoader instance;
- return instance;
-}
-
-bool SherpaOnnxLoader::Initialize()
-{
- std::lock_guard guard(g_loaderInitMutex);
-
- if (m_hModule) {
- return true; // Already initialized
- }
-
- // Prefer loading from the same directory as this module (adapter DLL / smoke test EXE).
- std::string moduleDir = GetCurrentModuleDirectory();
- AppendLoaderLog(std::string("[loader] moduleDir=") + moduleDir);
- if (!moduleDir.empty()) {
- const std::string ortPath = moduleDir + "\\onnxruntime.dll";
- HMODULE existingOrt = GetModuleHandleA("onnxruntime.dll");
- if (existingOrt) {
- std::string existingPath = GetModulePath(existingOrt);
- AppendLoaderLog(std::string("[loader] existing onnxruntime.dll path=") + existingPath);
- if (existingPath.empty()) {
- m_lastError = "onnxruntime.dll is already loaded but module path could not be resolved.";
- AppendLoaderLog(std::string("[loader] ERROR: ") + m_lastError);
- return false;
- }
- if (_stricmp(existingPath.c_str(), ortPath.c_str()) != 0) {
- m_lastError = "Conflicting onnxruntime.dll already loaded from: ";
- m_lastError += existingPath;
- m_lastError += ". Expected: ";
- m_lastError += ortPath;
- AppendLoaderLog(std::string("[loader] ERROR: ") + m_lastError);
- return false;
- }
- }
- else {
- AppendLoaderLog(std::string("[loader] preloading onnxruntime from ") + ortPath);
- HMODULE ortModule = LoadLibraryExA(ortPath.c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH);
- if (!ortModule) {
- m_lastError = "Failed to preload onnxruntime.dll from ";
- m_lastError += ortPath;
- m_lastError += " (error ";
- m_lastError += ToHexError(::GetLastError());
- m_lastError += ").";
- AppendLoaderLog(std::string("[loader] ERROR: ") + m_lastError);
- return false;
- }
- AppendLoaderLog(std::string("[loader] preloaded onnxruntime handle path=") + GetModulePath(ortModule));
- }
-
- std::string explicitPath = moduleDir + "\\sherpa-onnx-c-api.dll";
- AppendLoaderLog(std::string("[loader] loading sherpa dll from ") + explicitPath);
- m_hModule = LoadLibraryExA(explicitPath.c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH);
- }
-
- if (!m_hModule) {
- // Fallback: search by name.
- m_hModule = LoadLibraryA("sherpa-onnx-c-api.dll");
- }
-
- if (!m_hModule) {
- m_lastError = "Failed to load sherpa-onnx-c-api.dll. ";
- m_lastError += "Please ensure the SherpaOnnx DLLs are in the application directory.";
- AppendLoaderLog(std::string("[loader] ERROR: ") + m_lastError);
- return false;
- }
- AppendLoaderLog(std::string("[loader] sherpa module path=") + GetModulePath(m_hModule));
-
- // Get all function pointers
- bool success = true;
- success &= GetFunction("SherpaOnnxCreateOfflineTts", SherpaOnnxCreateOfflineTts);
- success &= GetFunction("SherpaOnnxDestroyOfflineTts", SherpaOnnxDestroyOfflineTts);
- success &= GetFunction("SherpaOnnxOfflineTtsGenerate", SherpaOnnxOfflineTtsGenerate);
- SherpaOnnxOfflineTtsGenerateWithProgressCallbackWithArg =
- reinterpret_cast(
- GetProcAddress(m_hModule, "SherpaOnnxOfflineTtsGenerateWithProgressCallbackWithArg"));
- success &= GetFunction("SherpaOnnxDestroyOfflineTtsGeneratedAudio", SherpaOnnxDestroyOfflineTtsGeneratedAudio);
- success &= GetFunction("SherpaOnnxOfflineTtsSampleRate", SherpaOnnxOfflineTtsSampleRate);
- success &= GetFunction("SherpaOnnxOfflineTtsNumSpeakers", SherpaOnnxOfflineTtsNumSpeakers);
-
- if (!success) {
- // Clean up on failure
- FreeLibrary(m_hModule);
- m_hModule = nullptr;
- return false;
- }
-
- return true;
-}
-
-} // namespace Dynamic
-} // namespace SherpaOnnx
diff --git a/SherpaOnnx/SherpaOnnxDynamic.h b/SherpaOnnx/SherpaOnnxDynamic.h
deleted file mode 100644
index c26551d..0000000
--- a/SherpaOnnx/SherpaOnnxDynamic.h
+++ /dev/null
@@ -1,74 +0,0 @@
-#pragma once
-
-// Dynamic loading wrapper for SherpaOnnx C API
-// This allows using SherpaOnnx without static linking to the libraries
-
-#include
-#include
-#include
-#include
-#include
-
-extern "C" {
-#include
-} // extern "C"
-
-namespace SherpaOnnx {
-namespace Dynamic {
-
-// Singleton class that manages SherpaOnnx DLL loading and function pointers
-class SherpaOnnxLoader {
-public:
- static SherpaOnnxLoader& Instance();
-
- // Initialize the loader (loads DLL and gets function pointers)
- bool Initialize();
-
- // Check if the loader is initialized
- bool IsInitialized() const { return m_hModule != nullptr; }
-
- // Get the last error message
- const std::string& GetLastError() const { return m_lastError; }
-
- // Function pointers to SherpaOnnx C API
- const SherpaOnnxOfflineTts* (*SherpaOnnxCreateOfflineTts)(const SherpaOnnxOfflineTtsConfig*);
- void (*SherpaOnnxDestroyOfflineTts)(const SherpaOnnxOfflineTts*);
- const SherpaOnnxGeneratedAudio* (*SherpaOnnxOfflineTtsGenerate)(
- const SherpaOnnxOfflineTts*, const char*, int, float);
- const SherpaOnnxGeneratedAudio* (*SherpaOnnxOfflineTtsGenerateWithProgressCallbackWithArg)(
- const SherpaOnnxOfflineTts*, const char*, int, float, SherpaOnnxGeneratedAudioProgressCallbackWithArg, void*);
- void (*SherpaOnnxDestroyOfflineTtsGeneratedAudio)(const SherpaOnnxGeneratedAudio*);
- int (*SherpaOnnxOfflineTtsSampleRate)(const SherpaOnnxOfflineTts*);
- int (*SherpaOnnxOfflineTtsNumSpeakers)(const SherpaOnnxOfflineTts*);
-
-private:
- SherpaOnnxLoader();
- ~SherpaOnnxLoader();
-
- // Prevent copying
- SherpaOnnxLoader(const SherpaOnnxLoader&) = delete;
- SherpaOnnxLoader& operator=(const SherpaOnnxLoader&) = delete;
-
- HMODULE m_hModule = nullptr;
- std::string m_lastError;
-
- // Get a function pointer by name
- template
- bool GetFunction(const char* name, T& funcPtr) {
- funcPtr = reinterpret_cast(GetProcAddress(m_hModule, name));
- if (!funcPtr) {
- m_lastError = "Failed to get function: ";
- m_lastError += name;
- return false;
- }
- return true;
- }
-};
-
-// Inline getter for the loader instance
-inline SherpaOnnxLoader& Loader() {
- return SherpaOnnxLoader::Instance();
-}
-
-} // namespace Dynamic
-} // namespace SherpaOnnx
diff --git a/SherpaOnnx/SherpaOnnxEngine.cpp b/SherpaOnnx/SherpaOnnxEngine.cpp
deleted file mode 100644
index 4b1417e..0000000
--- a/SherpaOnnx/SherpaOnnxEngine.cpp
+++ /dev/null
@@ -1,413 +0,0 @@
-#include "SherpaOnnxEngine.h"
-#include
-#include
-#include
-
-namespace SherpaOnnx {
-
-namespace {
-struct ProgressCallbackContext {
- const std::function* callback = nullptr;
-};
-
-int32_t InvokeProgressCallback(const float* samples, int32_t n, float p, void* arg) {
- auto* ctx = reinterpret_cast(arg);
- if (!ctx || !ctx->callback) {
- return 0;
- }
- return (*ctx->callback)(samples, n, p) ? 1 : 0;
-}
-
-bool IsExistingFile(const std::string& path) {
- if (path.empty()) {
- return false;
- }
- std::error_code ec;
- return std::filesystem::is_regular_file(std::filesystem::u8path(path), ec);
-}
-
-bool IsExistingDirectory(const std::string& path) {
- if (path.empty()) {
- return false;
- }
- std::error_code ec;
- return std::filesystem::is_directory(std::filesystem::u8path(path), ec);
-}
-}
-
-Engine::Engine(const ModelConfig& config)
- : m_config(config)
-{
- std::string validationError;
- if (!ValidateConfig(validationError)) {
- m_lastError = "Invalid Sherpa config: " + validationError;
- return;
- }
-
- // Initialize dynamic loader first
- if (!Dynamic::Loader().Initialize()) {
- m_lastError = "Failed to initialize SherpaOnnx DLL: ";
- m_lastError += Dynamic::Loader().GetLastError();
- return;
- }
-
- // Build C API configuration
- SherpaOnnxOfflineTtsConfig apiConfig = {};
- if (m_config.modelType == TtsModelType::Vits) {
- // Baseline parity path: match known-good vanilla C usage.
- m_ownedStrings.clear();
- m_ownedStrings.reserve(12);
- apiConfig.model.vits.model = PersistString(m_config.vits.model);
- apiConfig.model.vits.tokens = PersistString(m_config.vits.tokens);
- apiConfig.model.vits.data_dir = PersistString(m_config.vits.dataDir);
- apiConfig.model.num_threads = (std::max)(1, m_config.numThreads);
- apiConfig.model.provider = PersistString(m_config.provider.empty() ? "cpu" : m_config.provider);
- apiConfig.max_num_sentences = (std::max)(1, m_config.maxNumSentences);
- } else {
- apiConfig = BuildCApiConfig();
- }
-
- // Create the TTS engine (uses dynamic loading)
- m_tts = Dynamic::Loader().SherpaOnnxCreateOfflineTts(&apiConfig);
-
- // Some Piper/VITS packs can fail init when data_dir is present but incompatible.
- // Retry once without data_dir to provide a robust baseline path.
- if (!m_tts && m_config.modelType == TtsModelType::Vits && !m_config.vits.dataDir.empty()) {
- SherpaOnnxOfflineTtsConfig retryConfig = apiConfig;
- retryConfig.model.vits.data_dir = nullptr;
- m_tts = Dynamic::Loader().SherpaOnnxCreateOfflineTts(&retryConfig);
- if (m_tts) {
- m_lastError.clear();
- return;
- }
- }
-
- if (!m_tts) {
- m_lastError = "Failed to create SherpaOnnx TTS engine";
- }
-
- // Free the temporary C strings used in config
- // (SherpaOnnx should make its own copies)
-}
-
-Engine::~Engine()
-{
- if (m_tts) {
- Dynamic::Loader().SherpaOnnxDestroyOfflineTts(m_tts);
- m_tts = nullptr;
- }
-}
-
-std::vector Engine::Generate(const std::string& text, float speed)
-{
- if (!m_tts) {
- m_lastError = "Engine not initialized";
- return {};
- }
-
- if (text.empty()) {
- return {};
- }
-
- // Generate speech (speaker 0, default speed)
- const SherpaOnnxGeneratedAudio* audio =
- Dynamic::Loader().SherpaOnnxOfflineTtsGenerate(m_tts, text.c_str(), 0, speed);
-
- if (!audio) {
- m_lastError = "Failed to generate audio";
- return {};
- }
-
- // Convert to vector
- std::vector result = ConvertGeneratedAudio(audio);
-
- // Clean up
- Dynamic::Loader().SherpaOnnxDestroyOfflineTtsGeneratedAudio(audio);
-
- return result;
-}
-
-bool Engine::ValidateConfig(std::string& error) const
-{
- auto requireFile = [&error](const char* field, const std::string& value) -> bool {
- if (value.empty()) {
- error = std::string(field) + " is required";
- return false;
- }
- if (!IsExistingFile(value)) {
- error = std::string(field) + " does not exist: " + value;
- return false;
- }
- return true;
- };
-
- auto optionalDir = [&error](const char* field, const std::string& value) -> bool {
- if (value.empty()) {
- return true;
- }
- if (!IsExistingDirectory(value)) {
- error = std::string(field) + " is not a directory: " + value;
- return false;
- }
- return true;
- };
-
- auto optionalFileOrDir = [&error](const char* field, const std::string& value) -> bool {
- if (value.empty()) {
- return true;
- }
- if (!IsExistingFile(value) && !IsExistingDirectory(value)) {
- error = std::string(field) + " does not exist: " + value;
- return false;
- }
- return true;
- };
-
- switch (m_config.modelType) {
- case TtsModelType::Matcha:
- if (!requireFile("matcha.acousticModel", m_config.matcha.acousticModel)) return false;
- if (!requireFile("matcha.vocoder", m_config.matcha.vocoder)) return false;
- if (!requireFile("matcha.tokens", m_config.matcha.tokens)) return false;
- if (!optionalFileOrDir("matcha.lexicon", m_config.matcha.lexicon)) return false;
- if (!optionalDir("matcha.dataDir", m_config.matcha.dataDir)) return false;
- if (!optionalDir("matcha.dictDir", m_config.matcha.dictDir)) return false;
- break;
- case TtsModelType::Kokoro:
- if (!requireFile("kokoro.model", m_config.kokoro.model)) return false;
- if (!requireFile("kokoro.voices", m_config.kokoro.voices)) return false;
- if (!requireFile("kokoro.tokens", m_config.kokoro.tokens)) return false;
- if (!optionalFileOrDir("kokoro.lexicon", m_config.kokoro.lexicon)) return false;
- if (!optionalDir("kokoro.dataDir", m_config.kokoro.dataDir)) return false;
- if (!optionalDir("kokoro.dictDir", m_config.kokoro.dictDir)) return false;
- break;
- case TtsModelType::Vits:
- default:
- if (!requireFile("vits.model", m_config.vits.model)) return false;
- if (!requireFile("vits.tokens", m_config.vits.tokens)) return false;
- if (!optionalFileOrDir("vits.lexicon", m_config.vits.lexicon)) return false;
- if (!optionalDir("vits.dataDir", m_config.vits.dataDir)) return false;
- if (!optionalDir("vits.dictDir", m_config.vits.dictDir)) return false;
- break;
- }
-
- if (m_config.provider.empty()) {
- error = "provider is required";
- return false;
- }
- if (m_config.numThreads <= 0) {
- error = "numThreads must be > 0";
- return false;
- }
-
- return true;
-}
-
-bool Engine::GenerateWithProgressCallback(
- const std::string& text,
- float speed,
- const std::function& onChunk)
-{
- if (!m_tts) {
- m_lastError = "Engine not initialized";
- return false;
- }
-
- if (text.empty()) {
- return true;
- }
-
- if (!Dynamic::Loader().SherpaOnnxOfflineTtsGenerateWithProgressCallbackWithArg) {
- std::vector fallback = Generate(text, speed);
- if (fallback.empty()) {
- return false;
- }
- return onChunk(fallback.data(), static_cast(fallback.size()), 1.0f);
- }
-
- ProgressCallbackContext ctx{ &onChunk };
- const SherpaOnnxGeneratedAudio* audio =
- Dynamic::Loader().SherpaOnnxOfflineTtsGenerateWithProgressCallbackWithArg(
- m_tts,
- text.c_str(),
- 0,
- speed,
- InvokeProgressCallback,
- &ctx);
-
- if (!audio) {
- m_lastError = "Failed to generate audio with callback";
- return false;
- }
-
- Dynamic::Loader().SherpaOnnxDestroyOfflineTtsGeneratedAudio(audio);
- return true;
-}
-
-int Engine::GetSampleRate() const
-{
- if (!m_tts) {
- return 0;
- }
- return Dynamic::Loader().SherpaOnnxOfflineTtsSampleRate(m_tts);
-}
-
-int Engine::GetNumSpeakers() const
-{
- if (!m_tts) {
- return 0;
- }
- return Dynamic::Loader().SherpaOnnxOfflineTtsNumSpeakers(m_tts);
-}
-
-std::vector Engine::ConvertGeneratedAudio(
- const SherpaOnnxGeneratedAudio* audio)
-{
- if (!audio) {
- return {};
- }
-
- // SherpaOnnxGeneratedAudio has direct fields:
- // const float *samples; // in the range [-1, 1]
- // int32_t n; // number of samples
- // int32_t sample_rate;
-
- const float* data = audio->samples;
- int32_t numSamples = audio->n;
-
- if (!data || numSamples <= 0) {
- return {};
- }
-
- return std::vector(data, data + numSamples);
-}
-
-SherpaOnnxOfflineTtsConfig Engine::BuildCApiConfig()
-{
- m_ownedStrings.clear();
- m_ownedStrings.reserve(48);
-
- // Build model config based on model type
- SherpaOnnxOfflineTtsModelConfig modelConfig = {};
- modelConfig.vits.model = PersistString("");
- modelConfig.vits.lexicon = PersistString("");
- modelConfig.vits.tokens = PersistString("");
- modelConfig.vits.data_dir = PersistString("");
- modelConfig.vits.noise_scale = 0.667f;
- modelConfig.vits.noise_scale_w = 0.8f;
- modelConfig.vits.length_scale = 1.0f;
- modelConfig.vits.dict_dir = PersistString("");
-
- modelConfig.matcha.acoustic_model = PersistString("");
- modelConfig.matcha.vocoder = PersistString("");
- modelConfig.matcha.lexicon = PersistString("");
- modelConfig.matcha.tokens = PersistString("");
- modelConfig.matcha.data_dir = PersistString("");
- modelConfig.matcha.noise_scale = 0.667f;
- modelConfig.matcha.length_scale = 1.0f;
- modelConfig.matcha.dict_dir = PersistString("");
-
- modelConfig.kokoro.model = PersistString("");
- modelConfig.kokoro.voices = PersistString("");
- modelConfig.kokoro.tokens = PersistString("");
- modelConfig.kokoro.data_dir = PersistString("");
- modelConfig.kokoro.length_scale = 1.0f;
- modelConfig.kokoro.dict_dir = PersistString("");
- modelConfig.kokoro.lexicon = PersistString("");
- modelConfig.kokoro.lang = PersistString("");
-
- modelConfig.kitten.model = PersistString("");
- modelConfig.kitten.voices = PersistString("");
- modelConfig.kitten.tokens = PersistString("");
- modelConfig.kitten.data_dir = PersistString("");
- modelConfig.kitten.length_scale = 1.0f;
-
- modelConfig.zipvoice.tokens = PersistString("");
- modelConfig.zipvoice.encoder = PersistString("");
- modelConfig.zipvoice.decoder = PersistString("");
- modelConfig.zipvoice.vocoder = PersistString("");
- modelConfig.zipvoice.data_dir = PersistString("");
- modelConfig.zipvoice.lexicon = PersistString("");
- modelConfig.zipvoice.feat_scale = 0.1f;
- modelConfig.zipvoice.t_shift = 0.5f;
- modelConfig.zipvoice.target_rms = 0.1f;
- modelConfig.zipvoice.guidance_scale = 1.0f;
-
- switch (m_config.modelType) {
- case TtsModelType::Matcha: {
- // Matcha-TTS configuration
- SherpaOnnxOfflineTtsMatchaModelConfig matchaConfig;
- matchaConfig.acoustic_model = PersistString(m_config.matcha.acousticModel);
- matchaConfig.vocoder = PersistString(m_config.matcha.vocoder);
- matchaConfig.tokens = PersistString(m_config.matcha.tokens);
- matchaConfig.lexicon = PersistString(m_config.matcha.lexicon);
- matchaConfig.data_dir = PersistString(m_config.matcha.dataDir);
- matchaConfig.dict_dir = PersistString(m_config.matcha.dictDir);
- matchaConfig.noise_scale = m_config.matcha.noiseScale;
- matchaConfig.length_scale = m_config.matcha.lengthScale;
-
- modelConfig.matcha = matchaConfig;
- break;
- }
-
- case TtsModelType::Kokoro: {
- // Kokoro configuration
- SherpaOnnxOfflineTtsKokoroModelConfig kokoroConfig;
- kokoroConfig.model = PersistString(m_config.kokoro.model);
- kokoroConfig.voices = PersistString(m_config.kokoro.voices);
- kokoroConfig.tokens = PersistString(m_config.kokoro.tokens);
- kokoroConfig.lexicon = PersistString(m_config.kokoro.lexicon);
- kokoroConfig.data_dir = PersistString(m_config.kokoro.dataDir);
- kokoroConfig.dict_dir = PersistString(m_config.kokoro.dictDir);
- std::string kokoroLang = m_config.kokoro.lang.empty() ? "en-us" : m_config.kokoro.lang;
- kokoroConfig.lang = PersistString(kokoroLang);
- kokoroConfig.length_scale = m_config.kokoro.lengthScale;
-
- modelConfig.kokoro = kokoroConfig;
- break;
- }
-
- case TtsModelType::Vits:
- default: {
- // VITS/Piper/MMS configuration
- SherpaOnnxOfflineTtsVitsModelConfig vitsConfig;
- vitsConfig.model = PersistString(m_config.vits.model);
- vitsConfig.lexicon = PersistString(m_config.vits.lexicon);
- vitsConfig.tokens = PersistString(m_config.vits.tokens);
- vitsConfig.data_dir = PersistString(m_config.vits.dataDir);
- vitsConfig.dict_dir = PersistString(m_config.vits.dictDir);
- vitsConfig.noise_scale = m_config.vits.noiseScale;
- vitsConfig.noise_scale_w = m_config.vits.noiseScaleW;
- vitsConfig.length_scale = m_config.vits.lengthScale;
-
- modelConfig.vits = vitsConfig;
- break;
- }
- }
-
- modelConfig.num_threads = m_config.numThreads;
- modelConfig.debug = m_config.debug ? 1 : 0;
- modelConfig.provider = PersistString(m_config.provider);
-
- // Build main config
- SherpaOnnxOfflineTtsConfig config;
- config.model = modelConfig;
- config.rule_fsts = PersistString(m_config.ruleFsts);
- config.max_num_sentences = m_config.maxNumSentences;
- config.rule_fars = PersistString(m_config.ruleFars);
- config.silence_scale = m_config.silenceScale;
-
- return config;
-}
-
-const char* Engine::PersistString(const std::string& value, bool nullIfEmpty)
-{
- if (nullIfEmpty && value.empty()) {
- return nullptr;
- }
-
- m_ownedStrings.push_back(value);
- return m_ownedStrings.back().c_str();
-}
-
-} // namespace SherpaOnnx
-
diff --git a/SherpaOnnx/SherpaOnnxEngine.h b/SherpaOnnx/SherpaOnnxEngine.h
deleted file mode 100644
index 865f15f..0000000
--- a/SherpaOnnx/SherpaOnnxEngine.h
+++ /dev/null
@@ -1,59 +0,0 @@
-#pragma once
-
-#include "SherpaOnnxConfig.h"
-#include "SherpaOnnxDynamic.h"
-#include
-#include
-#include
-#include
-
-// Note: SherpaOnnx C API type forward declarations are in SherpaOnnxDynamic.h
-// We use dynamic loading for the actual functions
-
-namespace SherpaOnnx {
-
-// C++ wrapper around SherpaOnnx C API for TTS operations
-// Uses dynamic loading to avoid static library dependencies
-class Engine {
-public:
- explicit Engine(const ModelConfig& config);
- ~Engine();
-
- // Generate speech from text (returns float samples in [-1, 1] range)
- std::vector Generate(const std::string& text, float speed = 1.0f);
- bool GenerateWithProgressCallback(
- const std::string& text,
- float speed,
- const std::function& onChunk);
-
- // Get engine properties
- int GetSampleRate() const;
- int GetNumSpeakers() const;
- bool IsValid() const { return m_tts != nullptr; }
- const std::string& GetLastError() const { return m_lastError; }
- const std::string& GetVoiceName() const { return m_config.voiceName; }
-
- // Disable copy
- Engine(const Engine&) = delete;
- Engine& operator=(const Engine&) = delete;
-
-private:
- const SherpaOnnxOfflineTts* m_tts = nullptr;
- ModelConfig m_config;
- std::string m_lastError;
- std::vector m_ownedStrings;
-
- const char* PersistString(const std::string& value, bool nullIfEmpty = false);
-
- // Helper to convert SherpaOnnx audio to vector
- static std::vector ConvertGeneratedAudio(
- const SherpaOnnxGeneratedAudio* audio);
-
- // Build C API config from our config structure
- SherpaOnnxOfflineTtsConfig BuildCApiConfig();
-
- // Validate model-type specific config/files before calling into Sherpa.
- bool ValidateConfig(std::string& error) const;
-};
-
-} // namespace SherpaOnnx
diff --git a/VoiceGarden.UI/App.axaml b/VoiceGarden.UI/App.axaml
index bccb670..bf31fa4 100644
--- a/VoiceGarden.UI/App.axaml
+++ b/VoiceGarden.UI/App.axaml
@@ -2,7 +2,33 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="VoiceGarden.UI.App"
RequestedThemeVariant="Light">
+
+
+
+
+ #F97316
+ #EA680C
+ #FAF8F5
+ #0A0E1A
+ #64748B
+ #CBD5E1
+ #FFFFFF
+ #F1F5F9
+ #22C55E
+ #EF4444
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VoiceGarden.UI/App.axaml.cs b/VoiceGarden.UI/App.axaml.cs
index f4193b1..2f774d2 100644
--- a/VoiceGarden.UI/App.axaml.cs
+++ b/VoiceGarden.UI/App.axaml.cs
@@ -1,10 +1,20 @@
using Avalonia;
using Avalonia.Markup.Xaml;
+using Microsoft.Win32;
+using VoiceGarden.UI.Services;
namespace VoiceGarden.UI;
public partial class App : Application
{
+ private const string RegPath = @"SOFTWARE\VoiceGardenSAPIAdapter";
+
+ ///
+ /// Bump this when onboarding content changes to force all users to see it again.
+ /// Also used to detect fresh installs (stored version = 0).
+ ///
+ private const int CurrentOnboardingVersion = 1;
+
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -14,12 +24,68 @@ public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop)
{
- desktop.MainWindow = new MainWindow
+ var mainWindow = new MainWindow
{
DataContext = new ViewModels.MainViewModel(),
};
+ desktop.MainWindow = mainWindow;
+ mainWindow.Show();
+
+ if (ShouldShowOnboarding())
+ {
+ MarkOnboardingShown();
+ var onboarding = new OnboardingWindow();
+ onboarding.ShowDialog(mainWindow);
+ onboarding.Closed += (_, _) =>
+ {
+ if (onboarding.AnalyticsOptIn)
+ {
+ AnalyticsService.IsEnabled = true;
+ AnalyticsService.Track("analytics_opted_in");
+ }
+ };
+ }
}
base.OnFrameworkInitializationCompleted();
}
+
+ ///
+ /// Show onboarding if:
+ /// - Never seen before (OnboardingVersion missing = 0), OR
+ /// - Onboarding version was bumped (stored < current), OR
+ /// - Analytics consent was never answered (PromptShown missing)
+ ///
+ private static bool ShouldShowOnboarding()
+ {
+ try
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RegPath);
+ if (key == null) return true;
+
+ var storedVersion = (int)(key.GetValue("OnboardingVersion") ?? 0);
+ var promptShown = key.GetValue("AnalyticsPromptShown");
+
+ return storedVersion < CurrentOnboardingVersion || promptShown == null;
+ }
+ catch
+ {
+ return true;
+ }
+ }
+
+ ///
+ /// Record that onboarding was shown. AnalyticsId is preserved so PostHog
+ /// tracks the same machine across reinstalls/upgrades.
+ ///
+ private static void MarkOnboardingShown()
+ {
+ try
+ {
+ using var key = Registry.CurrentUser.CreateSubKey(RegPath, writable: true);
+ key?.SetValue("OnboardingVersion", CurrentOnboardingVersion, RegistryValueKind.DWord);
+ key?.SetValue("AnalyticsPromptShown", 1, RegistryValueKind.DWord);
+ }
+ catch { }
+ }
}
diff --git a/VoiceGarden.UI/Assets/app.ico b/VoiceGarden.UI/Assets/app.ico
new file mode 100644
index 0000000..d01f65f
Binary files /dev/null and b/VoiceGarden.UI/Assets/app.ico differ
diff --git a/VoiceGarden.UI/Assets/logo-small.png b/VoiceGarden.UI/Assets/logo-small.png
new file mode 100644
index 0000000..199dd1c
Binary files /dev/null and b/VoiceGarden.UI/Assets/logo-small.png differ
diff --git a/VoiceGarden.UI/Assets/logo.png b/VoiceGarden.UI/Assets/logo.png
new file mode 100644
index 0000000..816ffb1
Binary files /dev/null and b/VoiceGarden.UI/Assets/logo.png differ
diff --git a/VoiceGarden.UI/CliDispatcher.cs b/VoiceGarden.UI/CliDispatcher.cs
index 4e3de82..872c8e1 100644
--- a/VoiceGarden.UI/CliDispatcher.cs
+++ b/VoiceGarden.UI/CliDispatcher.cs
@@ -156,14 +156,13 @@ private static int RunVoices(string[] args)
opts.TryGetValue("region", out var region);
var asJson = args.Contains("--json");
- var creds = BuildCredentials(engine, key, region ?? "");
+ var creds = BuildRustCredentials(engine, key, region ?? "");
if (creds == null) { Console.Error.WriteLine($"Unknown engine: {engine}"); return 1; }
- var client = DotNetTtsWrapper.Models.TtsFactory.CreateClient(engine, creds);
- if (client == null) { Console.Error.WriteLine($"Could not create client"); return 1; }
+ using var client = new RustTtsWrapper.TtsClient(engine, creds);
Console.Error.WriteLine($"Fetching voices for {engine}...");
- var voices = client.GetVoicesAsync().GetAwaiter().GetResult();
+ var voices = client.GetVoices();
Console.Error.WriteLine($"Found {voices.Count} voices");
if (asJson)
@@ -171,9 +170,9 @@ private static int RunVoices(string[] args)
var json = System.Text.Json.JsonSerializer.Serialize(voices.Select(v => new
{
id = v.Id, name = v.Name,
- language = v.LanguageCodes?.FirstOrDefault()?.Bcp47 ?? "en-US",
- gender = v.Gender.ToString(),
- provider = v.Provider ?? engine
+ language = string.IsNullOrEmpty(v.Language) ? "en-US" : v.Language,
+ gender = v.Gender ?? "Unknown",
+ provider = v.Engine ?? engine
}));
Console.WriteLine(json);
}
@@ -181,7 +180,7 @@ private static int RunVoices(string[] args)
{
foreach (var v in voices)
{
- var lang = v.LanguageCodes?.FirstOrDefault()?.Bcp47 ?? "en-US";
+ var lang = string.IsNullOrEmpty(v.Language) ? "en-US" : v.Language;
Console.WriteLine($" {v.Id,-40} {v.Name,-30} {lang}");
}
}
@@ -198,23 +197,16 @@ private static int RunValidate(string[] args)
}
opts.TryGetValue("region", out var region);
- var creds = BuildCredentials(engine, key, region ?? "");
+ var creds = BuildRustCredentials(engine, key, region ?? "");
if (creds == null) { Console.Error.WriteLine($"Unknown engine: {engine}"); return 1; }
- var client = DotNetTtsWrapper.Models.TtsFactory.CreateClient(engine, creds);
- if (client == null) { Console.Error.WriteLine($"Could not create client"); return 1; }
-
Console.Write($"Validating {engine} credentials... ");
try
{
- var result = client.CheckCredentialsAsync().GetAwaiter().GetResult();
- if (result.IsValid)
- {
- Console.WriteLine($"OK ({result.AvailableVoiceCount} voices)");
- return 0;
- }
- Console.WriteLine($"INVALID: {result.ErrorMessage}");
- return 2;
+ using var client = new RustTtsWrapper.TtsClient(engine, creds);
+ var voices = client.GetVoices();
+ Console.WriteLine($"OK ({voices.Count} voices)");
+ return 0;
}
catch (Exception ex)
{
@@ -276,17 +268,18 @@ private static int RunUnpromote(string[] args)
return 1;
}
- private static DotNetTtsWrapper.Models.ITtsCredentials? BuildCredentials(string engine, string key, string region)
+ private static Dictionary? BuildRustCredentials(string engine, string key, string region)
{
return engine.ToLowerInvariant() switch
{
- "azure" => new DotNetTtsWrapper.Models.AzureCredentials { SubscriptionKey = key, Region = region },
- "openai" => new DotNetTtsWrapper.Models.OpenAICredentials { ApiKey = key },
- "elevenlabs" => new DotNetTtsWrapper.Models.ElevenLabsCredentials { ApiKey = key },
- "google" => new DotNetTtsWrapper.Models.GoogleCredentials { ApiKey = key },
- "polly" => new DotNetTtsWrapper.Models.PollyCredentials { AccessKeyId = key, Region = region },
- "cartesia" => new DotNetTtsWrapper.Models.CartesiaCredentials { ApiKey = key },
- "deepgram" => new DotNetTtsWrapper.Models.DeepgramCredentials { ApiKey = key },
+ "azure" => new() { { "subscriptionKey", key }, { "region", region } },
+ "openai" or "elevenlabs" or "google" or "cartesia" or "deepgram" or
+ "fishaudio" or "hume" or "mistral" or "murf" or "resemble" or
+ "unrealspeech" or "upliftai" or "xai" or "modelslab" =>
+ new() { { "apiKey", key } },
+ "watson" => new() { { "apiKey", key }, { "region", region } },
+ "playht" => new() { { "apiKey", key }, { "userId", region } },
+ "witai" => new() { { "token", key } },
_ => null,
};
}
diff --git a/VoiceGarden.UI/MainWindow.axaml b/VoiceGarden.UI/MainWindow.axaml
index da07fd3..ab6a462 100644
--- a/VoiceGarden.UI/MainWindow.axaml
+++ b/VoiceGarden.UI/MainWindow.axaml
@@ -7,7 +7,8 @@
Title="VoiceGarden"
Width="680" Height="720"
MinWidth="560" MinHeight="560"
- WindowStartupLocation="CenterScreen">
+ WindowStartupLocation="CenterScreen"
+ Background="White">
@@ -19,6 +20,11 @@
+
+
+
+
+
@@ -98,7 +104,9 @@
-
+
+
@@ -204,35 +212,51 @@
+
+
+
+
+
+
diff --git a/VoiceGarden.UI/MainWindow.axaml.cs b/VoiceGarden.UI/MainWindow.axaml.cs
index a164508..548372a 100644
--- a/VoiceGarden.UI/MainWindow.axaml.cs
+++ b/VoiceGarden.UI/MainWindow.axaml.cs
@@ -1,5 +1,8 @@
+using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
using VoiceGarden.UI.ViewModels;
namespace VoiceGarden.UI;
@@ -10,6 +13,14 @@ public MainWindow()
{
InitializeComponent();
AvaloniaXamlLoader.Load(this);
+
+ // Set window icon from embedded ICO resource
+ try
+ {
+ using var stream = AssetLoader.Open(new Uri("avares://VoiceGarden.UI/Assets/app.ico"));
+ Icon = new WindowIcon(stream);
+ }
+ catch { /* don't crash if icon missing */ }
}
private void Close_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
diff --git a/VoiceGarden.UI/Models/BrandingConfig.cs b/VoiceGarden.UI/Models/BrandingConfig.cs
index e66994c..3789c19 100644
--- a/VoiceGarden.UI/Models/BrandingConfig.cs
+++ b/VoiceGarden.UI/Models/BrandingConfig.cs
@@ -1,38 +1,18 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
-using System.IO;
-using System.Text.Json;
namespace VoiceGarden.UI.Models;
-public class BrandingConfig
+///
+/// Hardcoded configuration constants (was branding.json).
+///
+public static class BrandingConfig
{
- public string AppName { get; set; } = "VoiceGarden";
- public string InstallDir { get; set; } = "VoiceGardenSAPI";
- public bool ShowEdgeVoices { get; set; } = false;
- public bool ShowNarratorVoices { get; set; } = false;
- public bool ShowAdvancedSection { get; set; } = true;
- public bool DefaultSherpaEnabled { get; set; } = true;
- public bool DefaultAzureEnabled { get; set; } = false;
-
- public static BrandingConfig Load(string? path = null)
- {
- path ??= Path.Combine(System.AppContext.BaseDirectory, "branding.json");
- if (!File.Exists(path))
- return new BrandingConfig();
-
- try
- {
- var json = File.ReadAllText(path);
- var config = JsonSerializer.Deserialize(json);
- return config ?? new BrandingConfig();
- }
- catch
- {
- return new BrandingConfig();
- }
- }
+ public const string AppName = "VoiceGarden";
+ public const string InstallDir = "VoiceGardenSAPI";
+ public const bool DefaultSherpaEnabled = true;
+ public const bool DefaultAzureEnabled = false;
}
public class EngineDefinition
diff --git a/VoiceGarden.UI/OnboardingWindow.axaml b/VoiceGarden.UI/OnboardingWindow.axaml
new file mode 100644
index 0000000..e34aefc
--- /dev/null
+++ b/VoiceGarden.UI/OnboardingWindow.axaml
@@ -0,0 +1,248 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VoiceGarden.UI/OnboardingWindow.axaml.cs b/VoiceGarden.UI/OnboardingWindow.axaml.cs
new file mode 100644
index 0000000..02f6d99
--- /dev/null
+++ b/VoiceGarden.UI/OnboardingWindow.axaml.cs
@@ -0,0 +1,24 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace VoiceGarden.UI;
+
+public partial class OnboardingWindow : Window
+{
+ private readonly OnboardingWindowViewModel _vm;
+
+ public bool AnalyticsOptIn => _vm.AnalyticsOptIn;
+
+ public OnboardingWindow()
+ {
+ InitializeComponent();
+ _vm = new OnboardingWindowViewModel();
+ DataContext = _vm;
+ _vm.RequestClose += () => Close();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/VoiceGarden.UI/OnboardingWindowViewModel.cs b/VoiceGarden.UI/OnboardingWindowViewModel.cs
new file mode 100644
index 0000000..be3dc1b
--- /dev/null
+++ b/VoiceGarden.UI/OnboardingWindowViewModel.cs
@@ -0,0 +1,65 @@
+using Avalonia.Media;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace VoiceGarden.UI;
+
+public partial class OnboardingWindowViewModel : ObservableObject
+{
+ private static readonly IBrush ActiveDot = new SolidColorBrush(Color.Parse("#F97316"));
+ private static readonly IBrush InactiveDot = new SolidColorBrush(Color.Parse("#CBD5E1"));
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsPage1))]
+ [NotifyPropertyChangedFor(nameof(IsPage2))]
+ [NotifyPropertyChangedFor(nameof(IsPage3))]
+ [NotifyPropertyChangedFor(nameof(CanGoBack))]
+ [NotifyPropertyChangedFor(nameof(CanGoNext))]
+ [NotifyPropertyChangedFor(nameof(Dot1Brush))]
+ [NotifyPropertyChangedFor(nameof(Dot2Brush))]
+ [NotifyPropertyChangedFor(nameof(Dot3Brush))]
+ private int _currentPage = 1;
+
+ public bool IsPage1 => CurrentPage == 1;
+ public bool IsPage2 => CurrentPage == 2;
+ public bool IsPage3 => CurrentPage == 3;
+
+ public bool CanGoBack => CurrentPage > 1;
+ public bool CanGoNext => CurrentPage < 3;
+
+ public IBrush Dot1Brush => CurrentPage >= 1 ? ActiveDot : InactiveDot;
+ public IBrush Dot2Brush => CurrentPage >= 2 ? ActiveDot : InactiveDot;
+ public IBrush Dot3Brush => CurrentPage >= 3 ? ActiveDot : InactiveDot;
+
+ public bool AnalyticsOptIn { get; private set; }
+
+ [RelayCommand]
+ private void Next()
+ {
+ if (CurrentPage < 3)
+ CurrentPage++;
+ }
+
+ [RelayCommand]
+ private void Back()
+ {
+ if (CurrentPage > 1)
+ CurrentPage--;
+ }
+
+ [RelayCommand]
+ private void Accept()
+ {
+ AnalyticsOptIn = true;
+ RequestClose?.Invoke();
+ }
+
+ [RelayCommand]
+ private void Decline()
+ {
+ AnalyticsOptIn = false;
+ RequestClose?.Invoke();
+ }
+
+ public event Action? RequestClose;
+}
diff --git a/VoiceGarden.UI/Services/AnalyticsService.cs b/VoiceGarden.UI/Services/AnalyticsService.cs
new file mode 100644
index 0000000..0954f00
--- /dev/null
+++ b/VoiceGarden.UI/Services/AnalyticsService.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Win32;
+
+namespace VoiceGarden.UI.Services;
+
+///
+/// Privacy-friendly analytics via PostHog (EU hosted).
+/// Opt-in only. Anonymous machine ID. No PII, no text content, no API keys.
+///
+public class AnalyticsService
+{
+ private const string ApiKey = "phc_tExS6nJkQJynVY7WQGrjfGhrJbPcgxW7dGhkgwXSvDhc";
+ private const string Endpoint = "https://eu.i.posthog.com/capture/";
+ private const string RegPath = @"SOFTWARE\VoiceGardenSAPIAdapter";
+ private const string IdName = "AnalyticsId";
+ private const string EnabledName = "AnalyticsEnabled";
+ private const string PromptShownName = "AnalyticsPromptShown";
+
+ private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) };
+ private static string? _cachedId;
+
+ ///
+ /// Whether analytics is enabled (opt-in, default false).
+ ///
+ public static bool IsEnabled
+ {
+ get => GetBool(EnabledName, false);
+ set => SetBool(EnabledName, value);
+ }
+
+ ///
+ /// Whether the first-run consent dialog has been shown.
+ ///
+ public static bool PromptShown
+ {
+ get => GetBool(PromptShownName, false);
+ set => SetBool(PromptShownName, value);
+ }
+
+ ///
+ /// Anonymous machine identifier. Generated on first use, stored in registry.
+ ///
+ public static string DistinctId
+ {
+ get
+ {
+ if (_cachedId != null) return _cachedId;
+ _cachedId = GetString(IdName);
+ if (string.IsNullOrEmpty(_cachedId))
+ {
+ _cachedId = Guid.NewGuid().ToString("N");
+ SetString(IdName, _cachedId);
+ }
+ return _cachedId;
+ }
+ }
+
+ ///
+ /// Track an event. Fire-and-forget — never blocks the UI.
+ /// Only sends if IsEnabled is true.
+ ///
+ public static void Track(string eventName, params (string key, object value)[] properties)
+ {
+ if (!IsEnabled) return;
+
+ var payload = new
+ {
+ api_key = ApiKey,
+ @event = eventName,
+ properties = BuildProperties(properties),
+ timestamp = DateTime.UtcNow.ToString("o")
+ };
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(payload);
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ await _http.PostAsync(Endpoint, content);
+ }
+ catch { /* analytics should never break the app */ }
+ });
+ }
+
+ private static object BuildProperties((string key, object value)[] properties)
+ {
+ var dict = new System.Collections.Generic.Dictionary
+ {
+ ["distinct_id"] = DistinctId,
+ ["app_version"] = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "unknown",
+ };
+
+ foreach (var (key, value) in properties)
+ {
+ if (!string.IsNullOrEmpty(key))
+ dict[key] = value;
+ }
+
+ return dict;
+ }
+
+ // Registry helpers
+ private static bool GetBool(string name, bool defaultVal = false)
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RegPath);
+ var val = key?.GetValue(name);
+ return val is int i ? i != 0 : defaultVal;
+ }
+
+ private static void SetBool(string name, bool value)
+ {
+ using var key = Registry.CurrentUser.CreateSubKey(RegPath, writable: true);
+ key?.SetValue(name, value ? 1 : 0, RegistryValueKind.DWord);
+ }
+
+ private static string? GetString(string name)
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RegPath);
+ return key?.GetValue(name) as string;
+ }
+
+ private static void SetString(string name, string value)
+ {
+ using var key = Registry.CurrentUser.CreateSubKey(RegPath, writable: true);
+ key?.SetValue(name, value, RegistryValueKind.String);
+ }
+}
diff --git a/VoiceGarden.UI/ViewModels/MainViewModel.cs b/VoiceGarden.UI/ViewModels/MainViewModel.cs
index d12c629..990873e 100644
--- a/VoiceGarden.UI/ViewModels/MainViewModel.cs
+++ b/VoiceGarden.UI/ViewModels/MainViewModel.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
+using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
@@ -11,20 +12,45 @@
namespace VoiceGarden.UI.ViewModels;
-public partial class MainViewModel : ObservableObject
+// Simple disposable helper for event cleanup
+internal static class Disposable
{
- private readonly BrandingConfig _branding;
+ public static IDisposable Create(Action disposeAction) =>
+ new AnonymousDisposable(disposeAction);
+ private class AnonymousDisposable : IDisposable
+ {
+ private readonly Action _disposeAction;
+ private volatile bool _disposed;
+
+ public AnonymousDisposable(Action disposeAction)
+ {
+ _disposeAction = disposeAction ?? throw new ArgumentNullException(nameof(disposeAction));
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _disposeAction?.Invoke();
+ }
+ }
+}
+
+public partial class MainViewModel : ObservableObject, IDisposable
+{
public MainViewModel()
{
- _branding = BrandingConfig.Load();
- AppName = _branding.AppName;
- ShowAdvanced = false; // Hidden by default
+ AppName = BrandingConfig.AppName;
+ ShowAdvanced = false;
+
+ // Track app launch (only if opted in)
+ AnalyticsService.Track("app_launched");
// Load settings from registry
- SherpaEnabled = !RegistryService.GetFlag("NoSherpaVoices", !_branding.DefaultSherpaEnabled);
+ SherpaEnabled = !RegistryService.GetFlag("NoSherpaVoices", !BrandingConfig.DefaultSherpaEnabled);
EdgeEnabled = !RegistryService.GetFlag("NoEdgeVoices");
- NarratorEnabled = !RegistryService.GetFlag("NoNarratorVoices");
+ AnalyticsEnabled = Services.AnalyticsService.IsEnabled;
LogLevelIndex = RegistryService.GetDword("LogLevel", 0);
// Load cloud engines
@@ -45,12 +71,14 @@ public MainViewModel()
setting.Region = RegistryService.GetString("AzureVoiceRegion") ?? "eastus";
}
- setting.PropertyChanged += (s, e) =>
+ // Store handler and setting for later cleanup
+ PropertyChangedEventHandler handler = (s, e) =>
{
if (e.PropertyName == nameof(CloudEngineSetting.Enabled))
{
var eng = (CloudEngineSetting)s!;
RegistryService.SetFlag(eng.NoVoicesRegName, !eng.Enabled);
+ AnalyticsService.Track("engine_toggled", ("engine", eng.Id), ("enabled", eng.Enabled));
// Save Azure key to legacy location
if (eng.Id == "azure" && !string.IsNullOrEmpty(eng.ApiKey))
@@ -60,6 +88,11 @@ public MainViewModel()
}
}
};
+ setting.PropertyChanged += handler;
+
+ // Track subscription for cleanup using simple tuple
+ _eventSubscriptions.Add(Disposable.Create(() =>
+ setting.PropertyChanged -= handler));
CloudEngines.Add(setting);
}
@@ -71,11 +104,31 @@ public MainViewModel()
RefreshInstallStatus();
}
+ // Track event subscriptions for cleanup
+ private readonly List _eventSubscriptions = new List();
+
+ public void Dispose()
+ {
+ // Unsubscribe from all CloudEngineSetting events
+ foreach (var setting in CloudEngines.OfType())
+ {
+ // Manually unsubscribe by replacing with empty handler
+ // (C# event pattern doesn't provide direct unsubscribe for lambdas)
+ }
+ _eventSubscriptions.Clear();
+ }
+
[ObservableProperty] private string appName = "VoiceGarden";
[ObservableProperty] private bool showAdvanced = true;
[ObservableProperty] private bool sherpaEnabled = true;
[ObservableProperty] private bool edgeEnabled = false;
- [ObservableProperty] private bool narratorEnabled = false;
+ [ObservableProperty] private bool analyticsEnabled = false;
+
+ partial void OnAnalyticsEnabledChanged(bool value)
+ {
+ Services.AnalyticsService.IsEnabled = value;
+ if (value) Services.AnalyticsService.Track("analytics_opted_in");
+ }
[ObservableProperty] private int logLevelIndex = 0;
[ObservableProperty] private string status64Bit = "Checking...";
[ObservableProperty] private string status32Bit = "Checking...";
@@ -88,8 +141,8 @@ public MainViewModel()
public string AboutText =>
$"VoiceGarden v{System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)}\n\n" +
"SAPI Voice Adapter Configuration Tool\n\n" +
- $"DotNetTtsWrapper: {typeof(DotNetTtsWrapper.Models.TtsFactory).Assembly.GetName().Version}\n" +
- "Engines: Azure, OpenAI, ElevenLabs, Google, Polly, Cartesia, Deepgram,\n" +
+ $"RustTtsWrapper: {typeof(RustTtsWrapper.TtsClient).Assembly.GetName().Version}\n" +
+ "Engines: Azure, Edge, OpenAI, ElevenLabs, Google, Polly, Cartesia, Deepgram,\n" +
"SherpaOnnx (offline), Watson, PlayHT, Wit.ai, Gemini, and more\n\n" +
"https://github.com/AACTools/VoiceGarden-SAPI";
@@ -98,8 +151,11 @@ public MainViewModel()
public string AdvancedToggleText => ShowAdvanced ? "▼ Hide Advanced" : "▶ Show Advanced";
partial void OnSherpaEnabledChanged(bool value) => RegistryService.SetFlag("NoSherpaVoices", !value);
- partial void OnEdgeEnabledChanged(bool value) => RegistryService.SetFlag("NoEdgeVoices", !value);
- partial void OnNarratorEnabledChanged(bool value) => RegistryService.SetFlag("NoNarratorVoices", !value);
+ partial void OnEdgeEnabledChanged(bool value)
+ {
+ RegistryService.SetFlag("NoEdgeVoices", !value);
+ AnalyticsService.Track("engine_toggled", ("engine", "edge"), ("enabled", value));
+ }
partial void OnLogLevelIndexChanged(int value) => RegistryService.SetDword("LogLevel", value);
partial void OnShowAdvancedChanged(bool value) => OnPropertyChanged(nameof(AdvancedToggleText));
@@ -173,36 +229,38 @@ private void RefreshInstallStatus()
}
[RelayCommand]
- private void Install64()
+ private async Task Install64()
{
var rc = ComRegistrationService.Register(true);
if (rc == -2) return; // User cancelled UAC
- System.Threading.Thread.Sleep(500);
+ await Task.Delay(500); // Wait for registration to propagate
RefreshInstallStatus();
+ if (rc == 0) AnalyticsService.Track("adapter_registered", ("arch", "x64"));
}
[RelayCommand]
- private void Uninstall64()
+ private async Task Uninstall64()
{
ComRegistrationService.Unregister(true);
- System.Threading.Thread.Sleep(500);
+ await Task.Delay(500); // Wait for unregistration to propagate
RefreshInstallStatus();
}
[RelayCommand]
- private void Install32()
+ private async Task Install32()
{
var rc = ComRegistrationService.Register(false);
if (rc == -2) return;
- System.Threading.Thread.Sleep(500);
+ await Task.Delay(500); // Wait for registration to propagate
RefreshInstallStatus();
+ if (rc == 0) AnalyticsService.Track("adapter_registered", ("arch", "x86"));
}
[RelayCommand]
- private void Uninstall32()
+ private async Task Uninstall32()
{
ComRegistrationService.Unregister(false);
- System.Threading.Thread.Sleep(500);
+ await Task.Delay(500); // Wait for unregistration to propagate
RefreshInstallStatus();
}
diff --git a/VoiceGarden.UI/ViewModels/SherpaModelsViewModel.cs b/VoiceGarden.UI/ViewModels/SherpaModelsViewModel.cs
index c4982d1..49394dc 100644
--- a/VoiceGarden.UI/ViewModels/SherpaModelsViewModel.cs
+++ b/VoiceGarden.UI/ViewModels/SherpaModelsViewModel.cs
@@ -1,13 +1,12 @@
using System;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
-using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
-using DotNetTtsWrapper.Models;
-using DotNetTtsWrapper.Engines;
+using RustTtsWrapper;
using VoiceGarden.UI.Services;
namespace VoiceGarden.UI.ViewModels;
@@ -58,24 +57,24 @@ private async Task LoadCatalog()
try
{
- // Use DotNetTtsWrapper to get the unified voice list with proper BCP-47 codes
- var client = TtsFactory.CreateClient("sherpaonnx", new SherpaOnnxCredentials());
- var voices = await client.GetVoicesAsync();
+ // Load model catalog from merged_models.json (sidecar or embedded in RustTtsWrapper)
+ var catalog = await SherpaModelService.LoadCatalogAsync();
AllModels.Clear();
- foreach (var v in voices)
+ foreach (var cat in catalog)
{
- var langInfo = v.LanguageCodes?.FirstOrDefault();
- var installed = _installed.FirstOrDefault(i => i.Id == v.Id);
+ var langInfo = cat.Language?.FirstOrDefault();
+ var installed = _installed.FirstOrDefault(i => i.Id == cat.Id);
var item = new SherpaModelItem
{
- Id = v.Id,
- Name = v.Description ?? v.Name ?? v.Id,
- Language = langInfo?.Display ?? langInfo?.Bcp47 ?? "Unknown",
- ModelType = v.Description?.Contains("kokoro") == true ? "kokoro"
- : v.Description?.Contains("matcha") == true ? "matcha"
+ Id = cat.Id,
+ Name = string.IsNullOrEmpty(cat.Name) ? cat.Id : cat.Name,
+ Language = langInfo?.LanguageName ?? "Unknown",
+ ModelType = cat.ModelType?.Contains("kokoro") == true ? "kokoro"
+ : cat.ModelType?.Contains("matcha") == true ? "matcha"
: "vits",
- Url = "", // URL not available from TtsVoice, would need catalog
+ Url = cat.Url ?? "",
+ FileSizeMb = (long)(cat.FileSizeMb ?? 0),
IsDownloaded = installed != null,
IsPromoted = installed?.IsPromoted ?? false,
};
@@ -182,6 +181,9 @@ private async Task DownloadSelected()
}
UpdateCounts();
+ if (okCount > 0)
+ Services.AnalyticsService.Track("model_downloaded", ("count", okCount), ("failed", failCount));
+
StatusText = failCount == 0
? $"Downloaded {okCount} model(s)"
: okCount > 0
@@ -228,6 +230,7 @@ private void PromoteAll()
if (elevPromoted > 0)
{
+ Services.AnalyticsService.Track("voices_promoted", ("engine", "sherpaonnx"), ("count", elevPromoted));
StatusText = elevFailed == 0
? $"Installed {elevPromoted} model(s) to SAPI"
: $"Installed {elevPromoted}, failed {elevFailed}";
@@ -270,33 +273,49 @@ private async Task PreviewModel(SherpaModelItem model)
return;
}
- var creds = new DotNetTtsWrapper.Models.SherpaOnnxCredentials
+ // Use rust-tts-wrapper for preview (same engine as the SAPI adapter)
+ // Derive modelId and modelPath from the installed model path
+ var modelId = "";
+ var modelBasePath = "";
+ if (installed.ModelPath != null)
{
- ModelFilePath = installed.ModelPath,
- TokensFilePath = installed.TokensPath,
- DataDirPath = installed.DataDir,
- };
- var client = DotNetTtsWrapper.Models.TtsFactory.CreateClient("sherpaonnx", creds);
- if (client == null)
+ var p = System.IO.Path.GetDirectoryName(installed.ModelPath);
+ while (p != null && System.IO.Path.GetFileName(p) != "models")
+ p = System.IO.Path.GetDirectoryName(p);
+ if (p != null && System.IO.Path.GetFileName(p) == "models")
+ {
+ var rel = System.IO.Path.GetRelativePath(p, System.IO.Path.GetDirectoryName(installed.ModelPath)!);
+ modelId = rel.Split(System.IO.Path.DirectorySeparatorChar)[0];
+ modelBasePath = p;
+ }
+ }
+
+ if (string.IsNullOrEmpty(modelId))
{
- StatusText = "Could not create SherpaOnnx client";
+ StatusText = "Could not determine model ID for preview";
return;
}
- client.SetVoice(model.Id);
- // Use language-appropriate preview text — MMS models only recognize
- // characters from their target language (e.g., Persian model can't
- // synthesize English text).
+ using var client = new RustTtsWrapper.TtsClient("sherpaonnx", new Dictionary
+ {
+ { "modelId", modelId },
+ { "modelPath", modelBasePath }
+ });
+
+ // Use language-appropriate preview text
var previewText = GetPreviewText(model);
- var result = await client.SynthToBytesAsync(previewText);
- if (result?.AudioData?.Length > 0)
+ StatusText = $"Previewing {model.Name}...";
+ var audioData = client.SynthToBytes(previewText);
+ if (audioData.Length > 0)
{
+ // Rust returns raw PCM16 mono — wrap in WAV header for SoundPlayer
+ var wavData = WrapPcmInWav(audioData, 24000);
var tempFile = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"vg_sherpa_{Guid.NewGuid():N}.wav");
- await System.IO.File.WriteAllBytesAsync(tempFile, result.AudioData);
+ await System.IO.File.WriteAllBytesAsync(tempFile, wavData);
_ = Task.Run(() =>
{
try { using var p = new System.Media.SoundPlayer(tempFile); p.PlaySync(); }
- catch { }
+ catch (Exception playEx) { System.Diagnostics.Debug.WriteLine($"SoundPlayer: {playEx.Message}"); }
finally { try { System.IO.File.Delete(tempFile); } catch { } }
});
model.DownloadStatus = "Downloaded";
@@ -305,13 +324,14 @@ private async Task PreviewModel(SherpaModelItem model)
else
{
model.DownloadStatus = "Downloaded";
- StatusText = $"No audio — {model.Name} may need {model.Language} text. Voice is still installed and usable.";
+ StatusText = $"No audio generated for {model.Name}";
}
}
- catch (Exception ex)
+ catch (RustTtsWrapper.TtsException ex)
{
model.DownloadStatus = "Downloaded";
StatusText = $"Preview failed: {ex.Message}";
+ System.Diagnostics.Debug.WriteLine($"Preview exception for {model.Id}: {ex}");
}
}
@@ -335,7 +355,7 @@ private static string GetPreviewText(SherpaModelItem model)
{
"fas" => "سلام، این یک صدای فارسی است.", // Persian
"ara" => "مرحبا، هذه تجربة صوتية.", // Arabic
- "hyw" or "hye" => "Բարեւ, սա ձայնային փորձարկում է:", // Armenian
+ "hyw" or "hye" => "Բարև, սա ձայնային փորձարկում է:", // Armenian
"hin" => "नमस्ते, यह एक आवाज परीक्षण है।", // Hindi
"ben" => "হ্যালো, এটি একটি ভয়েস পরীক্ষা।", // Bengali
"urd" => "ہیلو، یہ ایک آواز کا ٹیسٹ ہے۔", // Urdu
@@ -351,6 +371,7 @@ private static string GetPreviewText(SherpaModelItem model)
"spa" => "Hola, esta es una prueba de voz.", // Spanish
"por" => "Olá, este é um teste de voz.", // Portuguese
"ita" => "Ciao, questo è un test vocale.", // Italian
+ "guj" => "નમસ્તે, આ એક અવાજ ચકાસણી છે.", // Gujarati
_ => $"[test] {langCode}", // Fallback — may produce no audio
};
}
@@ -359,6 +380,35 @@ private static string GetPreviewText(SherpaModelItem model)
return $"Hello. {model.Name}.";
}
+ ///
+ /// Wrap raw PCM16 mono samples in a WAV header so SoundPlayer can play them.
+ ///
+ private static byte[] WrapPcmInWav(byte[] pcm, int sampleRate)
+ {
+ using var ms = new System.IO.MemoryStream();
+ using var bw = new System.IO.BinaryWriter(ms);
+ short channels = 1;
+ short bitsPerSample = 16;
+ int byteRate = sampleRate * channels * bitsPerSample / 8;
+ short blockAlign = (short)(channels * bitsPerSample / 8);
+
+ bw.Write(System.Text.Encoding.ASCII.GetBytes("RIFF"));
+ bw.Write(36 + pcm.Length);
+ bw.Write(System.Text.Encoding.ASCII.GetBytes("WAVE"));
+ bw.Write(System.Text.Encoding.ASCII.GetBytes("fmt "));
+ bw.Write(16);
+ bw.Write((short)1);
+ bw.Write(channels);
+ bw.Write(sampleRate);
+ bw.Write(byteRate);
+ bw.Write(blockAlign);
+ bw.Write(bitsPerSample);
+ bw.Write(System.Text.Encoding.ASCII.GetBytes("data"));
+ bw.Write(pcm.Length);
+ bw.Write(pcm);
+ return ms.ToArray();
+ }
+
[RelayCommand]
private void OpenModelsFolder()
{
diff --git a/VoiceGarden.UI/ViewModels/VoiceConfigViewModel.cs b/VoiceGarden.UI/ViewModels/VoiceConfigViewModel.cs
index d8d47dd..18ddf3f 100644
--- a/VoiceGarden.UI/ViewModels/VoiceConfigViewModel.cs
+++ b/VoiceGarden.UI/ViewModels/VoiceConfigViewModel.cs
@@ -1,11 +1,10 @@
using System;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
-using DotNetTtsWrapper.Models;
-using DotNetTtsWrapper.Engines;
using VoiceGarden.UI.Services;
namespace VoiceGarden.UI.ViewModels;
@@ -80,16 +79,16 @@ private async Task FetchVoices()
try
{
- var creds = BuildCredentials();
- var client = TtsFactory.CreateClient(CurrentEngine, creds);
- if (client == null)
+ var creds = BuildRustCredentials();
+ if (creds == null)
{
StatusText = $"Unknown engine: {CurrentEngine}";
IsLoading = false;
return;
}
- var voices = await client.GetVoicesAsync();
+ using var client = new RustTtsWrapper.TtsClient(CurrentEngine, creds);
+ var voices = client.GetVoices();
TotalVoices = voices.Count;
foreach (var v in voices)
@@ -98,9 +97,9 @@ private async Task FetchVoices()
{
Id = v.Id ?? "",
Name = v.Name ?? v.Id ?? "",
- Language = v.LanguageCodes?.FirstOrDefault()?.Bcp47 ?? "en-US",
- Gender = v.Gender.ToString(),
- Provider = v.Provider ?? CurrentEngine,
+ Language = string.IsNullOrEmpty(v.Language) ? "en-US" : v.Language,
+ Gender = v.Gender ?? "Unknown",
+ Provider = v.Engine ?? CurrentEngine,
};
AllVoices.Add(item);
}
@@ -134,44 +133,22 @@ private async Task ValidateKey()
try
{
- var creds = BuildCredentials();
- var client = TtsFactory.CreateClient(CurrentEngine, creds);
- if (client == null)
+ var creds = BuildRustCredentials();
+ if (creds == null)
{
ValidationResult = "Unknown engine";
return;
}
- // Try CheckCredentialsAsync first
- var result = await client.CheckCredentialsAsync();
- if (result.IsValid)
- {
- ValidationResult = $"Valid ({result.AvailableVoiceCount} voices)";
- IsValidated = true;
- return;
- }
-
- // Fall back to synthesis test
- try
- {
- var synthResult = await client.SynthToBytesAsync("test");
- if (synthResult?.AudioData?.Length > 0)
- {
- ValidationResult = "Valid (synthesis test passed)";
- IsValidated = true;
- return;
- }
- }
- catch (Exception ex)
- {
- ValidationResult = $"Invalid: {ex.Message}";
- }
-
- IsValidated = false;
+ // Try to list voices as credential validation
+ using var client = new RustTtsWrapper.TtsClient(CurrentEngine, creds);
+ var voices = client.GetVoices();
+ ValidationResult = $"Valid ({voices.Count} voices)";
+ IsValidated = true;
}
- catch (Exception ex)
+ catch (RustTtsWrapper.TtsException ex)
{
- ValidationResult = $"Error: {ex.Message}";
+ ValidationResult = $"Invalid: {ex.Message}";
IsValidated = false;
}
finally
@@ -237,33 +214,25 @@ private async Task PreviewVoice(VoiceItem voice)
StatusText = $"Previewing {voice.Name}...";
try
{
- var creds = BuildCredentials();
+ // Use rust-tts-wrapper for cloud voice preview
+ var creds = BuildRustCredentials();
if (creds == null) return;
- var client = TtsFactory.CreateClient(CurrentEngine, creds);
- if (client == null) return;
-
+ using var client = new RustTtsWrapper.TtsClient(CurrentEngine, creds);
client.SetVoice(voice.Id);
- var tempFile = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"voicegarden_preview_{Guid.NewGuid():N}.wav");
-
- // Use SynthToFileAsync with WAV format — SynthToBytesAsync returns MP3
- // for some engines (Azure, OpenAI) which SoundPlayer can't play.
- await client.SynthToFileAsync($"Hello, my name is {voice.Name}.", tempFile, DotNetTtsWrapper.Models.AudioFormat.Wav);
-
- if (System.IO.File.Exists(tempFile) && new System.IO.FileInfo(tempFile).Length > 0)
+
+ var audioData = client.SynthToBytes($"Hello, my name is {voice.Name}.");
+ if (audioData.Length > 0)
{
- // Play without opening a media player window
+ // Rust returns raw PCM16 mono — wrap in WAV header for SoundPlayer
+ var wavData = WrapPcmInWav(audioData, 24000);
+ var tempFile = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"voicegarden_preview_{Guid.NewGuid():N}.wav");
+ await System.IO.File.WriteAllBytesAsync(tempFile, wavData);
_ = Task.Run(() =>
{
- try
- {
- using var player = new System.Media.SoundPlayer(tempFile);
- player.PlaySync();
- }
+ try { using var player = new System.Media.SoundPlayer(tempFile); player.PlaySync(); }
catch { }
- finally
- {
- try { System.IO.File.Delete(tempFile); } catch { }
+ finally { try { System.IO.File.Delete(tempFile); } catch { }
}
});
StatusText = $"Previewing {voice.Name}";
@@ -273,7 +242,7 @@ private async Task PreviewVoice(VoiceItem voice)
StatusText = "No audio generated";
}
}
- catch (Exception ex)
+ catch (RustTtsWrapper.TtsException ex)
{
StatusText = $"Preview failed: {ex.Message}";
}
@@ -293,25 +262,59 @@ private void SelectNone()
UpdateSelectedCount();
}
- private ITtsCredentials? BuildCredentials()
+ private Dictionary? BuildRustCredentials()
{
return CurrentEngine.ToLowerInvariant() switch
{
- "azure" => new AzureCredentials { SubscriptionKey = CurrentKey, Region = CurrentRegion },
- "openai" => new OpenAICredentials { ApiKey = CurrentKey },
- "elevenlabs" => new ElevenLabsCredentials { ApiKey = CurrentKey },
- "google" => new GoogleCredentials { ApiKey = CurrentKey },
- "polly" => new PollyCredentials { AccessKeyId = CurrentKey, SecretAccessKey = "", Region = CurrentRegion },
- "cartesia" => new CartesiaCredentials { ApiKey = CurrentKey },
- "deepgram" => new DeepgramCredentials { ApiKey = CurrentKey },
+ "azure" => new() { { "subscriptionKey", CurrentKey }, { "region", CurrentRegion } },
+ "openai" or "elevenlabs" or "google" or "cartesia" or "deepgram" or
+ "fishaudio" or "hume" or "mistral" or "murf" or "resemble" or
+ "unrealspeech" or "upliftai" or "xai" or "modelslab" =>
+ new() { { "apiKey", CurrentKey } },
+ "watson" => new() { { "apiKey", CurrentKey }, { "region", CurrentRegion } },
+ "playht" => new() { { "apiKey", CurrentKey }, { "userId", CurrentRegion } },
+ "witai" => new() { { "token", CurrentKey } },
_ => null,
};
}
+ ///
+ /// Wrap raw PCM16 mono samples in a WAV header so SoundPlayer can play them.
+ /// Rust's SynthToBytes returns raw PCM16, not WAV.
+ ///
+ private static byte[] WrapPcmInWav(byte[] pcm, int sampleRate)
+ {
+ using var ms = new System.IO.MemoryStream();
+ using var bw = new System.IO.BinaryWriter(ms);
+ short channels = 1;
+ short bitsPerSample = 16;
+ int byteRate = sampleRate * channels * bitsPerSample / 8;
+ short blockAlign = (short)(channels * bitsPerSample / 8);
+ int dataLen = pcm.Length;
+ int riffLen = 36 + dataLen;
+
+ bw.Write(System.Text.Encoding.ASCII.GetBytes("RIFF"));
+ bw.Write(riffLen);
+ bw.Write(System.Text.Encoding.ASCII.GetBytes("WAVE"));
+ bw.Write(System.Text.Encoding.ASCII.GetBytes("fmt "));
+ bw.Write(16); // PCM chunk size
+ bw.Write((short)1); // PCM format
+ bw.Write(channels);
+ bw.Write(sampleRate);
+ bw.Write(byteRate);
+ bw.Write(blockAlign);
+ bw.Write(bitsPerSample);
+ bw.Write(System.Text.Encoding.ASCII.GetBytes("data"));
+ bw.Write(dataLen);
+ bw.Write(pcm);
+ return ms.ToArray();
+ }
+
private void ApplyFilter()
{
FilteredVoices.Clear();
var filter = SearchFilter?.Trim().ToLowerInvariant() ?? "";
+
foreach (var v in AllVoices)
{
if (string.IsNullOrEmpty(filter) ||
@@ -323,7 +326,7 @@ private void ApplyFilter()
}
}
StatusText = string.IsNullOrEmpty(filter)
- ? $"Showing {TotalVoices} voices"
+ ? $"{TotalVoices} voices"
: $"Showing {FilteredVoices.Count} of {TotalVoices} voices";
}
diff --git a/VoiceGarden.UI/VoiceGarden.UI.csproj b/VoiceGarden.UI/VoiceGarden.UI.csproj
index 0606777..921f104 100644
--- a/VoiceGarden.UI/VoiceGarden.UI.csproj
+++ b/VoiceGarden.UI/VoiceGarden.UI.csproj
@@ -7,6 +7,7 @@
CA1416;MVVMTK0034
VoiceGarden.UI
VoiceGarden.UI
+ Assets\app.ico
true
true
win-x64
@@ -20,7 +21,7 @@
-
+
@@ -30,6 +31,10 @@
+
+
+
+
ResXFileCodeGenerator
diff --git a/VoiceGarden.png b/VoiceGarden.png
new file mode 100644
index 0000000..816ffb1
Binary files /dev/null and b/VoiceGarden.png differ
diff --git a/VoiceGardenSAPIAdapter.Net/ComRegistration.cs b/VoiceGardenSAPIAdapter.Net/ComRegistration.cs
deleted file mode 100644
index d79d657..0000000
--- a/VoiceGardenSAPIAdapter.Net/ComRegistration.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-using System;
-using System.Runtime.InteropServices;
-using Microsoft.Win32;
-using VoiceGardenSAPIAdapter.SapiInterop;
-
-namespace VoiceGardenSAPIAdapter;
-
-public static class ComRegistration
-{
- private static readonly string TokenEnumsPath = @"SOFTWARE\Microsoft\Speech\Voices\TokenEnums\VoiceGardenEnumerator";
- private static readonly string TTSEngineClsidPath = $@"CLSID\{{{SapiClsids.CLSID_TTSEngine}}}";
- private static readonly string EnumeratorClsidPath = $@"CLSID\{{{SapiClsids.CLSID_VoiceTokenEnumerator}}}";
-
- [ComRegisterFunction]
- public static void Register(Type t)
- {
- string dllPath = GetComHostDllPath();
-
- RegisterInprocServer(TTSEngineClsidPath, dllPath, "VoiceGardenSAPIAdapter.TTSEngine");
- RegisterInprocServer(EnumeratorClsidPath, dllPath, "VoiceGardenSAPIAdapter.VoiceTokenEnumerator");
-
- using (var key = Registry.LocalMachine.CreateSubKey(TokenEnumsPath))
- {
- key?.SetValue("CLSID", $"{{{SapiClsids.CLSID_VoiceTokenEnumerator}}}");
- }
- }
-
- [ComUnregisterFunction]
- public static void Unregister(Type t)
- {
- try { Registry.ClassesRoot.DeleteSubKeyTree(TTSEngineClsidPath); } catch { }
- try { Registry.ClassesRoot.DeleteSubKeyTree(EnumeratorClsidPath); } catch { }
- try { Registry.LocalMachine.DeleteSubKeyTree(TokenEnumsPath); } catch { }
- }
-
- private static void RegisterInprocServer(string clsidPath, string dllPath, string className)
- {
- using (var key = Registry.ClassesRoot.CreateSubKey(clsidPath))
- {
- key?.SetValue(null, className);
- }
-
- using (var key = Registry.ClassesRoot.CreateSubKey($@"{clsidPath}\InprocServer32"))
- {
- key?.SetValue(null, dllPath);
- key?.SetValue("ThreadingModel", "Both");
- }
- }
-
- private static string GetComHostDllPath()
- {
- string assemblyPath = typeof(ComRegistration).Assembly.Location;
- string? dir = System.IO.Path.GetDirectoryName(assemblyPath);
- string comHostName = System.IO.Path.GetFileNameWithoutExtension(assemblyPath) + ".comhost.dll";
- return dir != null
- ? System.IO.Path.Combine(dir, comHostName)
- : comHostName;
- }
-}
diff --git a/VoiceGardenSAPIAdapter.Net/CredentialBuilder.cs b/VoiceGardenSAPIAdapter.Net/CredentialBuilder.cs
deleted file mode 100644
index 51b07e4..0000000
--- a/VoiceGardenSAPIAdapter.Net/CredentialBuilder.cs
+++ /dev/null
@@ -1,98 +0,0 @@
-using System;
-using System.Runtime.InteropServices;
-using DotNetTtsWrapper.Models;
-using VoiceGardenSAPIAdapter.SapiInterop;
-
-namespace VoiceGardenSAPIAdapter;
-
-public static class CredentialBuilder
-{
- public static ITtsCredentials? FromTokenConfig(string engineName, ISpDataKey configKey)
- {
- string engine = engineName.ToLowerInvariant().Replace("-", "").Replace(" ", "");
-
- string? apiKey = TryGetString(configKey, "ApiKey");
- string? region = TryGetString(configKey, "Region");
-
- // SherpaOnnx tokens created by SherpaOnnxConfig use "Sherpa" as EngineType
- // and store explicit file paths (SherpaOnnxModelPath, SherpaOnnxTokens, etc.)
- if (engine == "sherpaonnx" || engine == "sherpa")
- {
- var modelFilePath = TryGetString(configKey, "SherpaOnnxModelPath");
- var tokensFilePath = TryGetString(configKey, "SherpaOnnxTokens");
- var dataDirPath = TryGetString(configKey, "SherpaOnnxDataDir");
- var lexiconPath = TryGetString(configKey, "SherpaOnnxLexicon");
-
- // If we have explicit file paths, use them
- if (!string.IsNullOrEmpty(modelFilePath))
- {
- return new SherpaOnnxCredentials
- {
- ModelFilePath = modelFilePath,
- TokensFilePath = tokensFilePath,
- DataDirPath = dataDirPath,
- LexiconFilePath = lexiconPath,
- ModelId = TryGetString(configKey, "VoiceId"),
- };
- }
-
- // Fall back to ModelPath/ModelId style
- return new SherpaOnnxCredentials
- {
- ModelPath = TryGetString(configKey, "ModelPath"),
- ModelId = TryGetString(configKey, "ModelId"),
- };
- }
-
- return engine switch
- {
- "azuresdk" or "azure" => new AzureCredentials
- {
- SubscriptionKey = apiKey ?? "",
- Region = region ?? "eastus"
- },
- "google" => new GoogleCredentials
- {
- ApiKey = apiKey ?? ""
- },
- "polly" or "awspolly" => new PollyCredentials
- {
- AccessKeyId = apiKey ?? "",
- SecretAccessKey = TryGetString(configKey, "SecretKey") ?? "",
- Region = region ?? "us-east-1"
- },
- "openai" => new OpenAICredentials
- {
- ApiKey = apiKey ?? ""
- },
- "elevenlabs" => new ElevenLabsCredentials
- {
- ApiKey = apiKey ?? ""
- },
- "cartesia" => new CartesiaCredentials
- {
- ApiKey = apiKey ?? ""
- },
- "deepgram" => new DeepgramCredentials
- {
- ApiKey = apiKey ?? ""
- },
- _ => null
- };
- }
-
- private static string? TryGetString(ISpDataKey key, string valueName)
- {
- try
- {
- key.GetStringValue(valueName, out IntPtr pVal);
- string? val = Marshal.PtrToStringUni(pVal);
- Marshal.FreeCoTaskMem(pVal);
- return val;
- }
- catch
- {
- return null;
- }
- }
-}
diff --git a/VoiceGardenSAPIAdapter.Net/Logger.cs b/VoiceGardenSAPIAdapter.Net/Logger.cs
deleted file mode 100644
index 266f9b6..0000000
--- a/VoiceGardenSAPIAdapter.Net/Logger.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using System;
-using System.IO;
-using System.Text;
-
-namespace VoiceGardenSAPIAdapter;
-
-internal static class Logger
-{
- private static readonly object _lock = new();
- private static string? _logPath;
- private static bool _initialized;
-
- private static void EnsureInitialized()
- {
- if (_initialized) return;
- _initialized = true;
-
- try
- {
- string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
- string logDir = Path.Combine(localAppData, "VoiceGardenSAPIAdapter");
- Directory.CreateDirectory(logDir);
- _logPath = Path.Combine(logDir, "dotnet-adapter.log.txt");
- }
- catch
- {
- _logPath = null;
- }
- }
-
- public static void Info(string message) => Write("INFO", message);
- public static void Warn(string message) => Write("WARN", message);
- public static void Error(string message) => Write("ERROR", message);
- public static void Error(string message, Exception ex) => Write("ERROR", $"{message}: {ex}");
-
- private static void Write(string level, string message)
- {
- System.Diagnostics.Debug.WriteLine($"[{level}] {message}");
-
- EnsureInitialized();
- if (_logPath == null) return;
-
- try
- {
- lock (_lock)
- {
- string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
- string line = $"[{timestamp}] [{level}] {message}{Environment.NewLine}";
- File.AppendAllText(_logPath, line, Encoding.UTF8);
- }
- }
- catch { }
- }
-}
diff --git a/VoiceGardenSAPIAdapter.Net/SapiInterop.cs b/VoiceGardenSAPIAdapter.Net/SapiInterop.cs
deleted file mode 100644
index fbca7c1..0000000
--- a/VoiceGardenSAPIAdapter.Net/SapiInterop.cs
+++ /dev/null
@@ -1,363 +0,0 @@
-using System;
-using System.Runtime.InteropServices;
-using System.Runtime.InteropServices.Marshalling;
-
-#pragma warning disable CS0649
-
-namespace VoiceGardenSAPIAdapter.SapiInterop;
-
-public static class SapiClsids
-{
- public static readonly Guid CLSID_TTSEngine = new("013AB33B-AD1A-401C-8BEE-F6E2B046A94E");
- public static readonly Guid CLSID_VoiceTokenEnumerator = new("B8B9E38F-E5A2-4661-9FDE-4AC7377AA6F6");
- public static readonly Guid CLSID_SpObjectToken = new("EF411752-3736-4CB4-9C8C-8EF4CCB58EFE");
- public static readonly Guid CLSID_SpObjectTokenEnum = new("3918D75F-0ACB-41F2-B733-92AA15BCECF6");
-}
-
-internal static class SapiIids
-{
- public static readonly Guid IID_ISpTTSEngine = new("A74D7C8E-4CC5-4F2F-A6EB-804DEE18500E");
- public static readonly Guid IID_ISpTTSEngineSite = new("9880499B-CCE9-11D2-B503-00C04F797396");
- public static readonly Guid IID_ISpObjectWithToken = new("5B559F40-E952-11D2-BB91-00C04F8EE6C0");
- public static readonly Guid IID_ISpObjectToken = new("14056589-E16C-11D2-BB90-00C04F8EE6C0");
- public static readonly Guid IID_ISpDataKey = new("14056581-E16C-11D2-BB90-00C04F8EE6C0");
- public static readonly Guid IID_IEnumSpObjectTokens = new("06B64F9E-7FDA-11D2-B4F2-00C04F797396");
- public static readonly Guid IID_ISpObjectTokenCategory = new("2D3D3845-39AF-4850-BBF9-40B49780011D");
-}
-
-public static class SapiEventIds
-{
- public const ushort SPEI_END_INPUT_STREAM = 14;
- public const ushort SPEI_TTS_BOOKMARK = 16;
- public const ushort SPEI_WORD_BOUNDARY = 17;
- public const ushort SPEI_PHONEME = 18;
- public const ushort SPEI_VISEME = 19;
- public const ushort SPEI_SENTENCE_BOUNDARY = 20;
- public const ushort SPEI_VISEME_CHANGED = 32;
- public const ushort SPEI_TTS_AUDIO_LEVEL = 33;
-}
-
-public static class SapiEventParamTypes
-{
- public const ushort SPET_LPARAM_IS_UNDEFINED = 0;
- public const ushort SPET_LPARAM_IS_TOKEN = 1;
- public const ushort SPET_LPARAM_IS_OBJECT = 2;
- public const ushort SPET_LPARAM_IS_POINTER = 3;
- public const ushort SPET_LPARAM_IS_STRING = 4;
-}
-
-public enum SPVACTIONS
-{
- SPVA_Speak = 0,
- SPVA_Silence = 1,
- SPVA_Pronounce = 2,
- SPVA_Bookmark = 3,
- SPVA_SpellOut = 4,
- SPVA_Section = 5,
- SPVA_ParseUnknownTag = 6,
-}
-
-public enum SPVSKIPTYPE
-{
- SPVST_SENTENCE = 1,
- SPVST_WORD = 2,
-}
-
-[StructLayout(LayoutKind.Sequential)]
-public struct SPVPITCH
-{
- public int MiddleAdj;
- public int RangeAdj;
-}
-
-[StructLayout(LayoutKind.Sequential)]
-public struct SPVCONTEXT
-{
- public IntPtr pCategory;
- public IntPtr pBefore;
- public IntPtr pAfter;
-}
-
-[StructLayout(LayoutKind.Sequential)]
-public struct SPVSTATE
-{
- public SPVACTIONS eAction;
- public ushort LangID;
- public ushort wReserved;
- public int EmphAdj;
- public int RateAdj;
- public uint Volume;
- public SPVPITCH PitchAdj;
- public uint SilenceMSecs;
- public IntPtr pPhoneIds;
- public int ePartOfSpeech;
- public SPVCONTEXT Context;
-}
-
-[StructLayout(LayoutKind.Sequential)]
-public struct SPVTEXTFRAG
-{
- public IntPtr pNext;
- public SPVSTATE State;
- public IntPtr pTextStart;
- public uint ulTextLen;
- public uint ulTextSrcOffset;
-}
-
-[StructLayout(LayoutKind.Sequential)]
-public struct SPEVENT
-{
- public ushort eEventId;
- public ushort elParamType;
- public uint ulStreamNum;
- public ulong ullAudioStreamOffset;
- public IntPtr wParam;
- public IntPtr lParam;
-}
-
-[StructLayout(LayoutKind.Sequential)]
-public struct WAVEFORMATEX
-{
- public ushort wFormatTag;
- public ushort nChannels;
- public uint nSamplesPerSec;
- public uint nAvgBytesPerSec;
- public ushort nBlockAlign;
- public ushort wBitsPerSample;
- public ushort cbSize;
-}
-
-[StructLayout(LayoutKind.Sequential)]
-internal struct WAVEFORMATEX_WITH_CB
-{
- public WAVEFORMATEX Format;
- public ushort cbAdditionalData;
-}
-
-[ComImport]
-[Guid("9880499B-CCE9-11D2-B503-00C04F797396")]
-[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
-[ComVisible(true)]
-public interface ISpTTSEngineSite
-{
- [PreserveSig]
- int AddEvents(IntPtr pEventArray, uint ulCount);
-
- [PreserveSig]
- int GetEventInterest(out ulong pullEventInterest);
-
- [PreserveSig]
- uint GetActions();
-
- [PreserveSig]
- int Write(IntPtr pBuff, uint cb, out uint pcbWritten);
-
- [PreserveSig]
- int GetRate(out int pRateAdjust);
-
- [PreserveSig]
- int GetVolume(out ushort pusVolume);
-
- [PreserveSig]
- int GetSkipInfo(out SPVSKIPTYPE peType, out int plNumItems);
-
- [PreserveSig]
- int CompleteSkip(int ulNumSkipped);
-}
-
-[ComImport]
-[Guid("A74D7C8E-4CC5-4F2F-A6EB-804DEE18500E")]
-[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
-[ComVisible(true)]
-internal interface ISpTTSEngine
-{
- [PreserveSig]
- int Speak(
- uint dwSpeakFlags,
- ref Guid rguidFormatId,
- IntPtr pWaveFormatEx,
- IntPtr pTextFragList,
- [In] ISpTTSEngineSite pOutputSite);
-
- [PreserveSig]
- int GetOutputFormat(
- IntPtr pTargetFmtId,
- IntPtr pTargetWaveFormatEx,
- out Guid pOutputFormatId,
- out IntPtr ppCoMemOutputWaveFormatEx);
-}
-
-[ComImport]
-[Guid("5B559F40-E952-11D2-BB91-00C04F8EE6C0")]
-[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
-[ComVisible(true)]
-public interface ISpObjectWithToken
-{
- [PreserveSig]
- int SetObjectToken([In] ISpObjectToken pToken);
-
- [PreserveSig]
- int GetObjectToken(out ISpObjectToken ppToken);
-}
-
-[ComImport]
-[Guid("14056589-E16C-11D2-BB90-00C04F8EE6C0")]
-[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
-[ComVisible(true)]
-public interface ISpObjectToken
-{
- [PreserveSig]
- int SetData([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName, uint cbData, [In] IntPtr pData);
-
- [PreserveSig]
- int GetData([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName, ref uint pcbData, [Out] IntPtr pData);
-
- [PreserveSig]
- int SetStringValue([In, MarshalAs(UnmanagedType.LPWStr)] string? pszValueName, [In, MarshalAs(UnmanagedType.LPWStr)] string pszValue);
-
- [PreserveSig]
- int GetStringValue([In, MarshalAs(UnmanagedType.LPWStr)] string? pszValueName, [Out] out IntPtr ppszValue);
-
- [PreserveSig]
- int SetDWORD([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName, uint dwValue);
-
- [PreserveSig]
- int GetDWORD([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName, out uint pdwValue);
-
- [PreserveSig]
- int OpenKey([In, MarshalAs(UnmanagedType.LPWStr)] string pszSubKeyName, out ISpDataKey ppSubKey);
-
- [PreserveSig]
- int CreateKey([In, MarshalAs(UnmanagedType.LPWStr)] string pszSubKey, out ISpDataKey ppSubKey);
-
- [PreserveSig]
- int DeleteKey([In, MarshalAs(UnmanagedType.LPWStr)] string pszSubKey);
-
- [PreserveSig]
- int DeleteValue([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName);
-
- [PreserveSig]
- int EnumKeys(uint Index, [Out] out IntPtr ppszSubKeyName);
-
- [PreserveSig]
- int EnumValues(uint Index, [Out] out IntPtr ppszValueName);
-
- [PreserveSig]
- int SetId([In, MarshalAs(UnmanagedType.LPWStr)] string? pCategoryId, [In, MarshalAs(UnmanagedType.LPWStr)] string pszTokenId, [In, MarshalAs(UnmanagedType.Bool)] bool fCreateIfNotExist);
-
- [PreserveSig]
- int GetId([Out] out IntPtr ppszCoMemTokenId);
-
- [PreserveSig]
- int GetCategory([Out] out ISpObjectTokenCategory ppTokenCategory);
-
- [PreserveSig]
- int CreateToken([In, MarshalAs(UnmanagedType.LPWStr)] string pTokenId, [Out] out ISpObjectToken ppToken);
-
- [PreserveSig]
- int GetStorageFileName([In] ref Guid clsidCaller, [In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName, [In, MarshalAs(UnmanagedType.LPWStr)] string pszFileNameOrElse, uint nFolder, [Out] out IntPtr ppszFilePath);
-
- [PreserveSig]
- int RemoveStorageFileName([In] ref Guid clsidCaller, [In, MarshalAs(UnmanagedType.LPWStr)] string pszKeyName, [In, MarshalAs(UnmanagedType.Bool)] bool fDeleteFile);
-
- [PreserveSig]
- int Remove([MarshalAs(UnmanagedType.LPWStr)] string? ppszCoMemTokenId);
-
- [PreserveSig]
- int IsUISupported([In, MarshalAs(UnmanagedType.LPWStr)] string pszTypeOfUI, [In] IntPtr pvExtraData, uint cbExtraData, [In] ISpObjectToken pTokenCur, [Out] out bool pfSupported);
-
- [PreserveSig]
- int DisplayUI([In] IntPtr hwndParent, [In, MarshalAs(UnmanagedType.LPWStr)] string pszTitle, [In, MarshalAs(UnmanagedType.LPWStr)] string pszTypeOfUI, [In] IntPtr pvExtraData, uint cbExtraData, [In] ISpObjectToken pTokenCur);
-}
-
-[ComImport]
-[Guid("14056581-E16C-11D2-BB90-00C04F8EE6C0")]
-[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
-[ComVisible(true)]
-public interface ISpDataKey
-{
- [PreserveSig]
- int SetData([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName, uint cbData, [In] IntPtr pData);
-
- [PreserveSig]
- int GetData([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName, ref uint pcbData, [Out] IntPtr pData);
-
- [PreserveSig]
- int SetStringValue([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName, [In, MarshalAs(UnmanagedType.LPWStr)] string pszValue);
-
- [PreserveSig]
- int GetStringValue([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName, [Out] out IntPtr ppszValue);
-
- [PreserveSig]
- int SetDWORD([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName, uint dwValue);
-
- [PreserveSig]
- int GetDWORD([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName, out uint pdwValue);
-
- [PreserveSig]
- int OpenKey([In, MarshalAs(UnmanagedType.LPWStr)] string pszSubKeyName, out ISpDataKey ppSubKey);
-
- [PreserveSig]
- int CreateKey([In, MarshalAs(UnmanagedType.LPWStr)] string pszSubKey, out ISpDataKey ppSubKey);
-
- [PreserveSig]
- int DeleteKey([In, MarshalAs(UnmanagedType.LPWStr)] string pszSubKey);
-
- [PreserveSig]
- int DeleteValue([In, MarshalAs(UnmanagedType.LPWStr)] string pszValueName);
-
- [PreserveSig]
- int EnumKeys(uint Index, [Out] out IntPtr ppszSubKeyName);
-
- [PreserveSig]
- int EnumValues(uint Index, [Out] out IntPtr ppszValueName);
-}
-
-[ComImport]
-[Guid("06B64F9E-7FDA-11D2-B4F2-00C04F797396")]
-[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
-[ComVisible(true)]
-public interface IEnumSpObjectTokens
-{
- [PreserveSig]
- int Next(uint celt, [Out] out ISpObjectToken pelt, out uint pceltFetched);
-
- [PreserveSig]
- int Skip(uint celt);
-
- [PreserveSig]
- int Reset();
-
- [PreserveSig]
- int Clone(out IEnumSpObjectTokens ppEnum);
-
- [PreserveSig]
- int Item(uint Index, out ISpObjectToken ppToken);
-
- [PreserveSig]
- int GetCount(out uint pCount);
-}
-
-[ComImport]
-[Guid("2D3D3845-39AF-4850-BBF9-40B49780011D")]
-[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
-[ComVisible(true)]
-public interface ISpObjectTokenCategory
-{
-}
-
-public static class SapiConstants
-{
- public const int S_OK = 0;
- public const int S_FALSE = 1;
- public const int E_NOTIMPL = unchecked((int)0x80004001);
- public const int E_POINTER = unchecked((int)0x80004003);
- public const int E_FAIL = unchecked((int)0x80004005);
- public const int E_INVALIDARG = unchecked((int)0x80070057);
- public const int E_OUTOFMEMORY = unchecked((int)0x8007000E);
- public const int CO_E_NOTINITIALIZED = unchecked((int)0x800401F0);
- public const uint SPVES_ABORT = 1;
- public const uint SPVES_RATE = 2;
- public const uint SPVES_VOLUME = 4;
- public static readonly Guid SPDFID_WaveFormatEx = new("C79ADBB0-3E93-4EB3-9463-CFCC4C7B0F36");
-}
diff --git a/VoiceGardenSAPIAdapter.Net/SpDataKey.cs b/VoiceGardenSAPIAdapter.Net/SpDataKey.cs
deleted file mode 100644
index 519b483..0000000
--- a/VoiceGardenSAPIAdapter.Net/SpDataKey.cs
+++ /dev/null
@@ -1,125 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Runtime.InteropServices;
-using VoiceGardenSAPIAdapter.SapiInterop;
-
-namespace VoiceGardenSAPIAdapter;
-
-[ComVisible(true)]
-[Guid("F2B3C4D5-E6F7-4A8B-9C0D-1E2F3A4B5C6D")]
-[ClassInterface(ClassInterfaceType.None)]
-public class SpDataKey : ISpDataKey
-{
- internal readonly Dictionary StringValues;
- internal readonly Dictionary DwordValues;
- internal readonly Dictionary SubKeys;
-
- public SpDataKey()
- {
- StringValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
- DwordValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
- SubKeys = new Dictionary(StringComparer.OrdinalIgnoreCase);
- }
-
- public void SetValue(string? name, string value) => StringValues[name ?? ""] = value;
- public void SetDwordValue(string name, uint value) => DwordValues[name] = value;
- public SpDataKey GetOrCreateSubKey(string name)
- {
- if (!SubKeys.TryGetValue(name, out var key))
- {
- key = new SpDataKey();
- SubKeys[name] = key;
- }
- return key;
- }
-
- public int SetData(string pszValueName, uint cbData, IntPtr pData) => SapiConstants.E_NOTIMPL;
- public int GetData(string pszValueName, ref uint pcbData, IntPtr pData) => SapiConstants.E_NOTIMPL;
-
- public int SetStringValue(string? pszValueName, string pszValue)
- {
- StringValues[pszValueName ?? ""] = pszValue;
- return SapiConstants.S_OK;
- }
-
- public int GetStringValue(string? pszValueName, out IntPtr ppszValue)
- {
- if (StringValues.TryGetValue(pszValueName ?? "", out var val))
- {
- ppszValue = Marshal.StringToCoTaskMemUni(val);
- return SapiConstants.S_OK;
- }
- ppszValue = IntPtr.Zero;
- return SapiConstants.E_INVALIDARG;
- }
-
- public int SetDWORD(string pszValueName, uint dwValue)
- {
- DwordValues[pszValueName] = dwValue;
- return SapiConstants.S_OK;
- }
-
- public int GetDWORD(string pszValueName, out uint pdwValue)
- {
- if (DwordValues.TryGetValue(pszValueName, out var val))
- {
- pdwValue = val;
- return SapiConstants.S_OK;
- }
- pdwValue = 0;
- return SapiConstants.E_INVALIDARG;
- }
-
- public int OpenKey(string pszSubKeyName, out ISpDataKey ppSubKey)
- {
- if (SubKeys.TryGetValue(pszSubKeyName, out var key))
- {
- ppSubKey = key;
- return SapiConstants.S_OK;
- }
- ppSubKey = null!;
- return SapiConstants.E_INVALIDARG;
- }
-
- public int CreateKey(string pszSubKey, out ISpDataKey ppSubKey)
- {
- var key = GetOrCreateSubKey(pszSubKey);
- ppSubKey = key;
- return SapiConstants.S_OK;
- }
-
- public int DeleteKey(string pszSubKey) => SubKeys.Remove(pszSubKey) ? SapiConstants.S_OK : SapiConstants.E_INVALIDARG;
- public int DeleteValue(string pszValueName) => StringValues.Remove(pszValueName) ? SapiConstants.S_OK : SapiConstants.E_INVALIDARG;
-
- public int EnumKeys(uint Index, out IntPtr ppszSubKeyName)
- {
- int i = 0;
- foreach (var kv in SubKeys)
- {
- if (i == Index)
- {
- ppszSubKeyName = Marshal.StringToCoTaskMemUni(kv.Key);
- return SapiConstants.S_OK;
- }
- i++;
- }
- ppszSubKeyName = IntPtr.Zero;
- return SapiConstants.S_FALSE;
- }
-
- public int EnumValues(uint Index, out IntPtr ppszValueName)
- {
- int i = 0;
- foreach (var kv in StringValues)
- {
- if (i == Index)
- {
- ppszValueName = Marshal.StringToCoTaskMemUni(kv.Key);
- return SapiConstants.S_OK;
- }
- i++;
- }
- ppszValueName = IntPtr.Zero;
- return SapiConstants.S_FALSE;
- }
-}
diff --git a/VoiceGardenSAPIAdapter.Net/SpObjectToken.cs b/VoiceGardenSAPIAdapter.Net/SpObjectToken.cs
deleted file mode 100644
index eb93abc..0000000
--- a/VoiceGardenSAPIAdapter.Net/SpObjectToken.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-using System;
-using System.Runtime.InteropServices;
-using VoiceGardenSAPIAdapter.SapiInterop;
-
-namespace VoiceGardenSAPIAdapter;
-
-[ComVisible(true)]
-[Guid("E1A2B3C4-D5E6-4F7A-8B9C-0D1E2F3A4B5C")]
-[ClassInterface(ClassInterfaceType.None)]
-public class SpObjectToken : ISpObjectToken
-{
- private readonly SpDataKey _data = new();
- private string _tokenId = "";
-
- public SpDataKey Data => _data;
-
- public int SetData(string pszValueName, uint cbData, IntPtr pData) => _data.SetData(pszValueName, cbData, pData);
- public int GetData(string pszValueName, ref uint pcbData, IntPtr pData) => _data.GetData(pszValueName, ref pcbData, pData);
- public int SetStringValue(string? pszValueName, string pszValue) => _data.SetStringValue(pszValueName, pszValue);
- public int GetStringValue(string? pszValueName, out IntPtr ppszValue) { Logger.Info($"SpObjectToken.GetStringValue('{pszValueName}')"); return _data.GetStringValue(pszValueName, out ppszValue); }
- public int SetDWORD(string pszValueName, uint dwValue) => _data.SetDWORD(pszValueName, dwValue);
- public int GetDWORD(string pszValueName, out uint pdwValue) { Logger.Info($"SpObjectToken.GetDWORD('{pszValueName}')"); return _data.GetDWORD(pszValueName, out pdwValue); }
- public int OpenKey(string pszSubKeyName, out ISpDataKey ppSubKey) { Logger.Info($"SpObjectToken.OpenKey('{pszSubKeyName}')"); return _data.OpenKey(pszSubKeyName, out ppSubKey); }
- public int CreateKey(string pszSubKey, out ISpDataKey ppSubKey) => _data.CreateKey(pszSubKey, out ppSubKey);
- public int DeleteKey(string pszSubKey) => _data.DeleteKey(pszSubKey);
- public int DeleteValue(string pszValueName) => _data.DeleteValue(pszValueName);
- public int EnumKeys(uint Index, out IntPtr ppszSubKeyName) => _data.EnumKeys(Index, out ppszSubKeyName);
- public int EnumValues(uint Index, out IntPtr ppszValueName) => _data.EnumValues(Index, out ppszValueName);
-
- public int SetId(string? pCategoryId, string pszTokenId, bool fCreateIfNotExist)
- {
- _tokenId = pszTokenId;
- return SapiConstants.S_OK;
- }
-
- public int GetId(out IntPtr ppszCoMemTokenId)
- {
- Logger.Info($"SpObjectToken.GetId() -> '{_tokenId}'");
- ppszCoMemTokenId = Marshal.StringToCoTaskMemUni(_tokenId);
- return SapiConstants.S_OK;
- }
-
- public int GetCategory(out ISpObjectTokenCategory ppTokenCategory)
- {
- ppTokenCategory = null!;
- return SapiConstants.E_NOTIMPL;
- }
-
- public int CreateToken(string pTokenId, out ISpObjectToken ppToken)
- {
- ppToken = null!;
- return SapiConstants.E_NOTIMPL;
- }
-
- public int GetStorageFileName(ref Guid clsidCaller, string pszValueName, string pszFileNameOrElse, uint nFolder, out IntPtr ppszFilePath)
- {
- ppszFilePath = IntPtr.Zero;
- return SapiConstants.E_NOTIMPL;
- }
-
- public int RemoveStorageFileName(ref Guid clsidCaller, string pszKeyName, bool fDeleteFile) => SapiConstants.E_NOTIMPL;
- public int Remove(string? ppszCoMemTokenId) => SapiConstants.E_NOTIMPL;
-
- public int IsUISupported(string pszTypeOfUI, IntPtr pvExtraData, uint cbExtraData, ISpObjectToken pTokenCur, out bool pfSupported)
- {
- pfSupported = false;
- return SapiConstants.S_OK;
- }
-
- public int DisplayUI(IntPtr hwndParent, string pszTitle, string pszTypeOfUI, IntPtr pvExtraData, uint cbExtraData, ISpObjectToken pTokenCur)
- {
- return SapiConstants.E_NOTIMPL;
- }
-}
diff --git a/VoiceGardenSAPIAdapter.Net/TTSEngine.cs b/VoiceGardenSAPIAdapter.Net/TTSEngine.cs
deleted file mode 100644
index a48ea38..0000000
--- a/VoiceGardenSAPIAdapter.Net/TTSEngine.cs
+++ /dev/null
@@ -1,488 +0,0 @@
-using System;
-using System.Runtime.InteropServices;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using DotNetTtsWrapper.Models;
-using VoiceGardenSAPIAdapter.SapiInterop;
-
-namespace VoiceGardenSAPIAdapter;
-
-[ComVisible(true)]
-[Guid("013AB33B-AD1A-401C-8BEE-F6E2B046A94E")]
-[ClassInterface(ClassInterfaceType.None)]
-public class TTSEngine : ISpTTSEngine, ISpObjectWithToken
-{
- private ISpObjectToken? _token;
- private string _engineName = "";
- private string _voiceId = "";
- private AbstractTtsClient? _ttsClient;
- private int _sampleRate = 24000;
- private ushort _bitsPerSample = 16;
- private ushort _channels = 1;
-
- public int Speak(
- uint dwSpeakFlags,
- ref Guid rguidFormatId,
- IntPtr pWaveFormatEx,
- IntPtr pTextFragList,
- ISpTTSEngineSite pOutputSite)
- {
- if (pTextFragList == IntPtr.Zero || pOutputSite == null)
- return SapiConstants.E_POINTER;
-
- Logger.Info($"Speak() called, engine='{_engineName}', voiceId='{_voiceId}'");
-
- EnsureTtsClient();
-
- if (_ttsClient == null)
- {
- Logger.Error($"Speak: TTS client is null after EnsureTtsClient (engine='{_engineName}')");
- return SapiConstants.E_FAIL;
- }
-
- var text = ExtractTextFromFragments(pTextFragList);
- if (string.IsNullOrEmpty(text))
- {
- Logger.Warn("Speak: no text extracted from fragments");
- return SapiConstants.S_OK;
- }
-
- Logger.Info($"Speak: synthesizing {text.Length} chars: \"{text.Substring(0, Math.Min(100, text.Length))}{(text.Length > 100 ? "..." : "")}\"");
-
- try
- {
- var options = BuildTtsOptions(pTextFragList);
- Logger.Info($"Speak: calling SynthToBytesAsync, engine='{_engineName}', voice='{_voiceId}'");
- var result = _ttsClient.SynthToBytesAsync(text, options).GetAwaiter().GetResult();
-
- if (result?.AudioData != null && result.AudioData.Length > 0)
- {
- byte[] pcmData = EnsurePcm16(result.AudioData);
- Logger.Info($"Speak: got {result.AudioData.Length} bytes audio (PCM: {pcmData.Length}), writing to site");
- WriteAudioToSite(pOutputSite, pcmData);
- }
- else
- {
- Logger.Warn($"Speak: synthesis returned no audio data (result={result != null}, audioLen={result?.AudioData?.Length ?? 0})");
- }
-
- FireEndStreamEvent(pOutputSite);
- }
- catch (Exception ex)
- {
- Logger.Error($"Speak: TTS synthesis error (engine='{_engineName}', voice='{_voiceId}')", ex);
- }
-
- return SapiConstants.S_OK;
- }
-
- public int GetOutputFormat(
- IntPtr pTargetFmtId,
- IntPtr pTargetWaveFormatEx,
- out Guid pOutputFormatId,
- out IntPtr ppCoMemOutputWaveFormatEx)
- {
- pOutputFormatId = SapiConstants.SPDFID_WaveFormatEx;
-
- int formatSize = Marshal.SizeOf();
- IntPtr pFormat = Marshal.AllocCoTaskMem(formatSize);
-
- var wf = new WAVEFORMATEX
- {
- wFormatTag = 1,
- nChannels = _channels,
- nSamplesPerSec = (uint)_sampleRate,
- wBitsPerSample = _bitsPerSample,
- };
- wf.nBlockAlign = (ushort)(wf.nChannels * wf.wBitsPerSample / 8);
- wf.nAvgBytesPerSec = wf.nSamplesPerSec * wf.nBlockAlign;
- wf.cbSize = 0;
-
- Marshal.StructureToPtr(wf, pFormat, false);
- ppCoMemOutputWaveFormatEx = pFormat;
-
- return SapiConstants.S_OK;
- }
-
- public int SetObjectToken(ISpObjectToken pToken)
- {
- _token = pToken;
- Logger.Info("SetObjectToken called");
- ReadVoiceConfig(pToken);
- Logger.Info($"SetObjectToken: engine='{_engineName}', voiceId='{_voiceId}', sampleRate={_sampleRate}");
- return SapiConstants.S_OK;
- }
-
- public int GetObjectToken(out ISpObjectToken ppToken)
- {
- ppToken = _token!;
- return _token != null ? SapiConstants.S_OK : SapiConstants.E_POINTER;
- }
-
- private void ReadVoiceConfig(ISpObjectToken token)
- {
- try
- {
- token.GetStringValue("EngineName", out IntPtr pEngine);
- _engineName = Marshal.PtrToStringUni(pEngine) ?? "";
- Marshal.FreeCoTaskMem(pEngine);
- }
- catch { }
-
- try
- {
- token.GetStringValue("VoiceId", out IntPtr pVoice);
- _voiceId = Marshal.PtrToStringUni(pVoice) ?? "";
- Marshal.FreeCoTaskMem(pVoice);
- }
- catch { }
-
- try
- {
- ISpDataKey configKey;
- token.OpenKey("VoiceGardenConfig", out configKey);
-
- try
- {
- configKey.GetStringValue("EngineName", out IntPtr pEngine);
- _engineName = Marshal.PtrToStringUni(pEngine) ?? _engineName;
- Marshal.FreeCoTaskMem(pEngine);
- }
- catch { }
-
- // SherpaOnnxConfig-created tokens use "EngineType" instead of "EngineName"
- if (string.IsNullOrEmpty(_engineName))
- {
- try
- {
- configKey.GetStringValue("EngineType", out IntPtr pType);
- var engineType = Marshal.PtrToStringUni(pType) ?? "";
- Marshal.FreeCoTaskMem(pType);
- if (engineType.Equals("Sherpa", StringComparison.OrdinalIgnoreCase))
- _engineName = "sherpaonnx";
- }
- catch { }
- }
-
- try
- {
- configKey.GetStringValue("VoiceId", out IntPtr pVoice);
- _voiceId = Marshal.PtrToStringUni(pVoice) ?? _voiceId;
- }
- catch { }
-
- // SherpaOnnxConfig tokens store model name in a different value
- if (string.IsNullOrEmpty(_voiceId))
- {
- try
- {
- configKey.GetStringValue("SherpaModelName", out IntPtr pModel);
- _voiceId = Marshal.PtrToStringUni(pModel) ?? _voiceId;
- Marshal.FreeCoTaskMem(pModel);
- }
- catch { }
- }
-
- try
- {
- configKey.GetDWORD("SampleRate", out uint sr);
- _sampleRate = (int)sr;
- }
- catch { }
- }
- catch { }
- }
-
- private void EnsureTtsClient()
- {
- if (_ttsClient != null) return;
-
- Logger.Info($"EnsureTtsClient: building client for engine='{_engineName}', voiceId='{_voiceId}'");
-
- ITtsCredentials? credentials = BuildCredentials();
-
- if (credentials == null)
- {
- Logger.Warn($"EnsureTtsClient: no credentials found for engine='{_engineName}'");
- }
- else
- {
- Logger.Info($"EnsureTtsClient: credentials type={credentials.GetType().Name}");
- }
-
- _ttsClient = TtsFactory.CreateClient(_engineName, credentials);
-
- if (_ttsClient == null)
- {
- Logger.Error($"EnsureTtsClient: TtsFactory.CreateClient returned null for engine='{_engineName}'");
- return;
- }
-
- if (!string.IsNullOrEmpty(_voiceId))
- {
- _ttsClient.SetVoice(_voiceId);
- Logger.Info($"EnsureTtsClient: voice set to '{_voiceId}'");
- }
- else
- {
- Logger.Warn("EnsureTtsClient: no voiceId set");
- }
- }
-
- private ITtsCredentials? BuildCredentials()
- {
- ITtsCredentials? creds = null;
-
- if (_token != null)
- {
- try
- {
- _token.OpenKey("VoiceGardenConfig", out ISpDataKey configKey);
- creds = CredentialBuilder.FromTokenConfig(_engineName, configKey);
- if (creds != null)
- {
- Logger.Info("BuildCredentials: from token VoiceGardenConfig");
- return creds;
- }
- }
- catch (Exception ex)
- {
- Logger.Warn($"BuildCredentials: token config not available: {ex.Message}");
- }
- }
-
- creds = TryReadRegistryCredentials();
- if (creds != null)
- {
- Logger.Info("BuildCredentials: from registry");
- return creds;
- }
-
- creds = EnvFallbackCredentials(_engineName);
- if (creds != null)
- {
- Logger.Info("BuildCredentials: from environment variables");
- }
- return creds;
- }
-
- private ITtsCredentials? TryReadRegistryCredentials()
- {
- try
- {
- using var baseKey = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(
- @"SOFTWARE\VoiceGardenSAPIAdapter\VoiceTokens");
- if (baseKey == null) return null;
-
- foreach (var subKeyName in baseKey.GetSubKeyNames())
- {
- if (!subKeyName.StartsWith(_engineName + "_", StringComparison.OrdinalIgnoreCase)) continue;
- using var subKey = baseKey.OpenSubKey(subKeyName);
- if (subKey == null) continue;
-
- using var configKey = subKey.OpenSubKey("VoiceGardenConfig");
- if (configKey == null) continue;
-
- string? voiceId = configKey.GetValue("VoiceId") as string;
- if (voiceId != _voiceId && !string.IsNullOrEmpty(_voiceId)) continue;
-
- var apiKey = configKey.GetValue("ApiKey") as string;
- var region = configKey.GetValue("Region") as string;
- var secretKey = configKey.GetValue("SecretKey") as string;
- var modelPath = configKey.GetValue("ModelPath") as string;
-
- if (string.IsNullOrEmpty(apiKey) && string.IsNullOrEmpty(modelPath)) continue;
-
- return _engineName.ToLowerInvariant() switch
- {
- "azure" => new AzureCredentials
- {
- SubscriptionKey = apiKey ?? "",
- Region = region ?? "eastus"
- },
- "openai" => new OpenAICredentials { ApiKey = apiKey ?? "" },
- "elevenlabs" => new ElevenLabsCredentials { ApiKey = apiKey ?? "" },
- "google" => new GoogleCredentials { ApiKey = apiKey ?? "" },
- "polly" => new PollyCredentials
- {
- AccessKeyId = apiKey ?? "",
- SecretAccessKey = secretKey ?? "",
- Region = region ?? "us-east-1"
- },
- "sherpaonnx" when !string.IsNullOrEmpty(modelPath) => new SherpaOnnxCredentials
- {
- ModelPath = modelPath,
- },
- "sherpaonnx" => new SherpaOnnxCredentials(),
- _ => null
- };
- }
- }
- catch { }
- return null;
- }
-
- private static ITtsCredentials? EnvFallbackCredentials(string engineName)
- {
- return engineName.ToLowerInvariant() switch
- {
- "azure" when !string.IsNullOrEmpty(
- Environment.GetEnvironmentVariable("AZURE_SPEECH_KEY")
- ?? Environment.GetEnvironmentVariable("MICROSOFT_TOKEN")) => new AzureCredentials
- {
- SubscriptionKey = Environment.GetEnvironmentVariable("AZURE_SPEECH_KEY")
- ?? Environment.GetEnvironmentVariable("MICROSOFT_TOKEN") ?? "",
- Region = Environment.GetEnvironmentVariable("AZURE_SPEECH_REGION")
- ?? Environment.GetEnvironmentVariable("MICROSOFT_REGION") ?? "eastus"
- },
- "openai" when !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) =>
- new OpenAICredentials { ApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "" },
- "elevenlabs" when !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ELEVENLABS_API_KEY")) =>
- new ElevenLabsCredentials { ApiKey = Environment.GetEnvironmentVariable("ELEVENLABS_API_KEY") ?? "" },
- "google" when !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GOOGLE_API_KEY")) =>
- new GoogleCredentials { ApiKey = Environment.GetEnvironmentVariable("GOOGLE_API_KEY") ?? "" },
- "polly" when !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID")) =>
- new PollyCredentials
- {
- AccessKeyId = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID") ?? "",
- SecretAccessKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY") ?? "",
- Region = Environment.GetEnvironmentVariable("AWS_REGION") ?? "us-east-1"
- },
- "sherpaonnx" or "sherpa" => new SherpaOnnxCredentials(),
- _ => null
- };
- }
-
- private static string ExtractTextFromFragments(IntPtr pTextFragList)
- {
- var sb = new StringBuilder();
- IntPtr current = pTextFragList;
-
- while (current != IntPtr.Zero)
- {
- var frag = Marshal.PtrToStructure(current);
- if (frag.State.eAction == SPVACTIONS.SPVA_Speak ||
- frag.State.eAction == SPVACTIONS.SPVA_SpellOut)
- {
- if (frag.pTextStart != IntPtr.Zero && frag.ulTextLen > 0)
- {
- string? text = Marshal.PtrToStringUni(frag.pTextStart, (int)frag.ulTextLen);
- if (text != null)
- sb.Append(text);
- }
- }
-
- current = frag.pNext;
- }
-
- return sb.ToString();
- }
-
- private static TtsOptions BuildTtsOptions(IntPtr pTextFragList)
- {
- var options = new TtsOptions { Format = AudioFormat.Wav };
-
- if (pTextFragList != IntPtr.Zero)
- {
- var frag = Marshal.PtrToStructure(pTextFragList);
- if (frag.State.RateAdj != 0)
- {
- options.Rate = MapSapiRate(frag.State.RateAdj);
- }
- if (frag.State.Volume > 0 && frag.State.Volume != 100)
- {
- options.Volume = (int)frag.State.Volume;
- }
- }
-
- return options;
- }
-
- private static SpeechRate MapSapiRate(int sapiRate)
- {
- return sapiRate switch
- {
- <= -5 => SpeechRate.XSlow,
- < 0 => SpeechRate.Slow,
- 0 => SpeechRate.Medium,
- < 5 => SpeechRate.Fast,
- _ => SpeechRate.XFast,
- };
- }
-
- private byte[] EnsurePcm16(byte[] audioData)
- {
- if (audioData.Length < 44) return audioData;
-
- if (audioData[0] == 'R' && audioData[1] == 'I' &&
- audioData[2] == 'F' && audioData[3] == 'F')
- {
- int dataOffset = 12;
- while (dataOffset < audioData.Length - 8)
- {
- string chunkId = System.Text.Encoding.ASCII.GetString(audioData, dataOffset, 4);
- int chunkSize = BitConverter.ToInt32(audioData, dataOffset + 4);
- if (chunkId == "data")
- {
- int pcmStart = dataOffset + 8;
- int pcmLength = Math.Min(chunkSize, audioData.Length - pcmStart);
- byte[] pcm = new byte[pcmLength];
- Buffer.BlockCopy(audioData, pcmStart, pcm, 0, pcmLength);
- return pcm;
- }
- dataOffset += 8 + chunkSize;
- if (chunkSize % 2 != 0) dataOffset++;
- }
- }
-
- return audioData;
- }
-
- private void WriteAudioToSite(ISpTTSEngineSite site, byte[] pcmData)
- {
- int offset = 0;
- while (offset < pcmData.Length)
- {
- uint actions = site.GetActions();
- if ((actions & SapiConstants.SPVES_ABORT) != 0)
- break;
-
- int chunkSize = Math.Min(4096, pcmData.Length - offset);
- IntPtr pBuffer = Marshal.AllocHGlobal(chunkSize);
- try
- {
- Marshal.Copy(pcmData, offset, pBuffer, chunkSize);
- site.Write(pBuffer, (uint)chunkSize, out _);
- }
- finally
- {
- Marshal.FreeHGlobal(pBuffer);
- }
- offset += chunkSize;
- }
- }
-
- private static void FireEndStreamEvent(ISpTTSEngineSite site)
- {
- var ev = new SPEVENT
- {
- eEventId = SapiEventIds.SPEI_END_INPUT_STREAM,
- elParamType = SapiEventParamTypes.SPET_LPARAM_IS_UNDEFINED,
- ulStreamNum = 0,
- ullAudioStreamOffset = 0,
- wParam = IntPtr.Zero,
- lParam = IntPtr.Zero,
- };
- IntPtr pEvent = Marshal.AllocHGlobal(Marshal.SizeOf());
- try
- {
- Marshal.StructureToPtr(ev, pEvent, false);
- site.AddEvents(pEvent, 1);
- }
- finally
- {
- Marshal.FreeHGlobal(pEvent);
- }
- }
-}
diff --git a/VoiceGardenSAPIAdapter.Net/VoiceGardenSAPIAdapter.Net.csproj b/VoiceGardenSAPIAdapter.Net/VoiceGardenSAPIAdapter.Net.csproj
deleted file mode 100644
index 8ce83d2..0000000
--- a/VoiceGardenSAPIAdapter.Net/VoiceGardenSAPIAdapter.Net.csproj
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
- net8.0
- true
- LatestMajor
- true
- enable
- enable
- VoiceGardenSAPIAdapter
- VoiceGardenSAPIAdapter.Net
- false
- $(DefaultItemExcludes);TestLocal\**;DownloadModel\**
-
-
-
-
-
-
-
diff --git a/VoiceGardenSAPIAdapter.Net/VoiceTokenEnumerator.cs b/VoiceGardenSAPIAdapter.Net/VoiceTokenEnumerator.cs
deleted file mode 100644
index a771340..0000000
--- a/VoiceGardenSAPIAdapter.Net/VoiceTokenEnumerator.cs
+++ /dev/null
@@ -1,489 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-using System.Threading.Tasks;
-using DotNetTtsWrapper.Models;
-using DotNetTtsWrapper.Engines;
-using Microsoft.Win32;
-using VoiceGardenSAPIAdapter.SapiInterop;
-
-namespace VoiceGardenSAPIAdapter;
-
-[ComVisible(true)]
-[Guid("B8B9E38F-E5A2-4661-9FDE-4AC7377AA6F6")]
-[ClassInterface(ClassInterfaceType.None)]
-public class VoiceTokenEnumerator : IEnumSpObjectTokens
-{
- private List _tokens = new();
- private int _currentPos;
-
- private static readonly string VoiceTokensBasePath = @"SOFTWARE\VoiceGardenSAPIAdapter\VoiceTokens";
-
- public VoiceTokenEnumerator()
- {
- _currentPos = 0;
- Logger.Info("VoiceTokenEnumerator created");
- try
- {
- InitDiscovery();
- }
- catch (Exception ex)
- {
- Logger.Error("VoiceTokenEnumerator init error", ex);
- }
- Logger.Info($"VoiceTokenEnumerator ready: {_tokens.Count} voices discovered");
- }
-
- [MethodImpl(MethodImplOptions.NoInlining)]
- private void InitDiscovery()
- {
- try
- {
- DiscoverVoicesAsync().GetAwaiter().GetResult();
- }
- catch (Exception ex)
- {
- Logger.Error("Voice discovery error", ex);
- }
- }
-
- private VoiceTokenEnumerator(List tokens, int currentPos)
- {
- _tokens = tokens;
- _currentPos = currentPos;
- }
-
- private async Task DiscoverVoicesAsync()
- {
- // First: discover SherpaOnnx voices registered in HKLM by SherpaOnnxConfig
- try
- {
- DiscoverSherpaOnnxTokensFromHklm();
- }
- catch (Exception ex)
- {
- Logger.Error("HKLM SherpaOnnx token discovery error", ex);
- }
-
- // Second: discover via DotNetTtsWrapper catalog
- try
- {
- var localEngines = new[] { "sherpaonnx" };
- foreach (var engine in localEngines)
- {
- ITtsCredentials? creds = engine.ToLowerInvariant() switch
- {
- "sherpaonnx" or "sherpa" => new SherpaOnnxCredentials(),
- _ => null
- };
- await DiscoverEngineVoices(engine, creds);
- }
- }
- catch (Exception ex)
- {
- Logger.Error("Local engine discovery error", ex);
- }
-
- try
- {
- await DiscoverConfiguredCloudVoices();
- }
- catch (Exception ex)
- {
- Logger.Error("Cloud engine discovery error", ex);
- }
- }
-
- private void DiscoverSherpaOnnxTokensFromHklm()
- {
- const string sapiTokensRoot = @"SOFTWARE\Microsoft\Speech\Voices\Tokens";
- try
- {
- using var tokensKey = Registry.LocalMachine.OpenSubKey(sapiTokensRoot);
- if (tokensKey == null) return;
-
- foreach (var tokenName in tokensKey.GetSubKeyNames())
- {
- if (!tokenName.StartsWith("Sherpa-", StringComparison.OrdinalIgnoreCase))
- continue;
-
- try
- {
- using var tokenKey = tokensKey.OpenSubKey(tokenName);
- if (tokenKey == null) continue;
-
- using var configKey = tokenKey.OpenSubKey("VoiceGardenConfig");
- if (configKey == null) continue;
-
- string? engineType = configKey.GetValue("EngineType") as string;
- if (engineType != "Sherpa") continue;
-
- string? modelPath = configKey.GetValue("SherpaOnnxModelPath") as string;
- string? tokensPath = configKey.GetValue("SherpaOnnxTokens") as string;
- string? dataDir = configKey.GetValue("SherpaOnnxDataDir") as string;
- string? modelName = configKey.GetValue("SherpaModelName") as string;
-
- if (string.IsNullOrEmpty(modelPath)) continue;
-
- using var attrsKey = tokenKey.OpenSubKey("Attributes");
- string? displayName = attrsKey?.GetValue("Name") as string ?? modelName ?? tokenName;
- string? locale = attrsKey?.GetValue("Locale") as string ?? "en-US";
- string? gender = attrsKey?.GetValue("Gender") as string ?? "Neutral";
-
- var token = new SpObjectToken();
- string tokenId = $@"HKEY_LOCAL_MACHINE\{sapiTokensRoot}\{tokenName}";
- token.SetId(null, tokenId, true);
- token.SetStringValue(null, displayName);
- token.SetStringValue("EngineName", "sherpaonnx");
- token.SetStringValue("VoiceId", modelName ?? tokenName);
- token.SetStringValue("CLSID", "{013AB33B-AD1A-401C-8BEE-F6E2B046A94E}");
-
- var attrs = token.Data.GetOrCreateSubKey("Attributes");
- attrs.SetStringValue("Name", displayName);
- attrs.SetStringValue("Gender", gender);
- attrs.SetStringValue("Age", "Adult");
- attrs.SetStringValue("Vendor", "K2FSA");
- attrs.SetStringValue("Locale", locale);
- try
- {
- ushort langId = LocaleToLangId(locale);
- attrs.SetStringValue("Language", $"0x{langId:X4}");
- }
- catch { }
- attrs.SetStringValue("VoiceGardenType", "Sherpa;Offline");
-
- var config = token.Data.GetOrCreateSubKey("VoiceGardenConfig");
- config.SetStringValue("EngineType", "Sherpa");
- config.SetStringValue("EngineName", "sherpaonnx");
- config.SetStringValue("VoiceId", modelName ?? tokenName);
- if (modelPath != null) config.SetStringValue("SherpaOnnxModelPath", modelPath);
- if (tokensPath != null) config.SetStringValue("SherpaOnnxTokens", tokensPath);
- if (dataDir != null) config.SetStringValue("SherpaOnnxDataDir", dataDir);
-
- _tokens.Add(token);
- Logger.Info($"HKLM SherpaOnnx token discovered: {tokenName} (model={modelPath})");
- }
- catch (Exception ex)
- {
- Logger.Error($"Error reading HKLM token {tokenName}", ex);
- }
- }
- }
- catch { }
- }
-
- private async Task DiscoverEngineVoices(string engine, ITtsCredentials? credentials)
- {
- try
- {
- Logger.Info($"Discovering voices for engine '{engine}'...");
- var client = TtsFactory.CreateClient(engine, credentials);
- if (client == null)
- {
- Logger.Warn($"TtsFactory.CreateClient returned null for '{engine}'");
- return;
- }
-
- List voices;
- try { voices = await client.GetVoicesAsync(); }
- catch (Exception ex) { Logger.Warn($"GetVoicesAsync failed for '{engine}': {ex.Message}"); return; }
-
- Logger.Info($"Engine '{engine}' returned {voices.Count} voices");
-
- foreach (var voice in voices)
- {
- try
- {
- var token = CreateVoiceToken(engine, voice, credentials);
- _tokens.Add(token);
- }
- catch (Exception ex)
- {
- Logger.Error($"Token creation error for {voice.Name}", ex);
- }
- }
- }
- catch (Exception ex)
- {
- Logger.Error($"Engine {engine} discovery error", ex);
- }
- }
-
- private async Task DiscoverConfiguredCloudVoices()
- {
- var cloudEngines = new (string engine, string[] configKeys)[]
- {
- ("azure", new[] { "AZURE_SPEECH_KEY", "MICROSOFT_TOKEN" }),
- ("openai", new[] { "OPENAI_API_KEY" }),
- ("elevenlabs", new[] { "ELEVENLABS_API_KEY" }),
- ("google", new[] { "GOOGLE_API_KEY" }),
- ("polly", new[] { "AWS_ACCESS_KEY_ID" }),
- };
-
- foreach (var (engine, envKeys) in cloudEngines)
- {
- string? credValue = null;
- foreach (var key in envKeys)
- {
- credValue = Environment.GetEnvironmentVariable(key);
- if (!string.IsNullOrEmpty(credValue)) break;
- }
-
- if (string.IsNullOrEmpty(credValue)) continue;
-
- try
- {
- var creds = BuildCloudCredentials(engine);
- if (creds == null) continue;
- await DiscoverEngineVoices(engine, creds);
- }
- catch (Exception ex)
- {
- Logger.Error($"Cloud engine {engine} discovery error", ex);
- }
- }
- }
-
- private static ITtsCredentials? BuildCloudCredentials(string engine)
- {
- return engine switch
- {
- "azure" => new AzureCredentials
- {
- SubscriptionKey = Env("AZURE_SPEECH_KEY") ?? Env("MICROSOFT_TOKEN") ?? "",
- Region = Env("AZURE_SPEECH_REGION") ?? Env("MICROSOFT_REGION") ?? "eastus"
- },
- "openai" => new OpenAICredentials { ApiKey = Env("OPENAI_API_KEY") ?? "" },
- "elevenlabs" => new ElevenLabsCredentials { ApiKey = Env("ELEVENLABS_API_KEY") ?? "" },
- "google" => new GoogleCredentials { ApiKey = Env("GOOGLE_API_KEY") ?? "" },
- "polly" => new PollyCredentials
- {
- AccessKeyId = Env("AWS_ACCESS_KEY_ID") ?? "",
- SecretAccessKey = Env("AWS_SECRET_ACCESS_KEY") ?? "",
- Region = Env("AWS_REGION") ?? "us-east-1"
- },
- _ => null
- };
- }
-
- private static string? Env(string name) => Environment.GetEnvironmentVariable(name);
-
- private ISpObjectToken CreateVoiceToken(string engineName, TtsVoice voice, ITtsCredentials? credentials)
- {
- var token = new SpObjectToken();
-
- string safeId = voice.Id?.Replace("/", "_").Replace("\\", "_") ?? "default";
- string tokenId = $@"HKEY_CURRENT_USER\{VoiceTokensBasePath}\{engineName}_{safeId}";
- token.SetId(null, tokenId, true);
- token.SetStringValue(null, voice.Name ?? engineName);
- token.SetStringValue("EngineName", engineName);
- token.SetStringValue("VoiceId", voice.Id);
- token.SetStringValue("CLSID", "{013AB33B-AD1A-401C-8BEE-F6E2B046A94E}");
-
- var attrs = token.Data.GetOrCreateSubKey("Attributes");
- attrs.SetStringValue("Name", voice.Name ?? engineName);
- attrs.SetStringValue("Gender", voice.Gender.ToString());
- attrs.SetStringValue("Age", "Adult");
- attrs.SetStringValue("Vendor", voice.Provider ?? "DotNetTtsWrapper");
-
- string langStr = "en-US";
- if (voice.LanguageCodes?.Count > 0)
- {
- langStr = voice.LanguageCodes[0].Bcp47 ?? "en-US";
- }
- attrs.SetStringValue("Locale", langStr);
-
- try
- {
- ushort langId = LocaleToLangId(langStr);
- attrs.SetStringValue("Language", $"0x{langId:X4}");
- }
- catch { }
-
- attrs.SetStringValue("VoiceGardenType", "DotNetTtsWrapper");
-
- var config = token.Data.GetOrCreateSubKey("VoiceGardenConfig");
- config.SetStringValue("EngineName", engineName);
- config.SetStringValue("VoiceId", voice.Id);
-
- if (credentials != null)
- {
- StoreCredentials(config, credentials);
- }
-
- PersistTokenToRegistry(token);
-
- Logger.Info($"Voice token created: engine={engineName}, id={voice.Id}, name={voice.Name}, locale={langStr}, gender={voice.Gender}");
-
- return token;
- }
-
- internal static void PersistTokenToRegistry(SpObjectToken token)
- {
- try
- {
- token.GetId(out IntPtr pId);
- string? tokenId = Marshal.PtrToStringUni(pId);
- Marshal.FreeCoTaskMem(pId);
-
- if (string.IsNullOrEmpty(tokenId)) return;
-
- string regPath = tokenId;
- if (regPath.StartsWith("HKEY_CURRENT_USER\\", StringComparison.OrdinalIgnoreCase))
- regPath = regPath.Substring("HKEY_CURRENT_USER\\".Length);
-
- using var key = Registry.CurrentUser.CreateSubKey(regPath);
- if (key == null) return;
-
- PersistDataKey(key, token.Data, "");
- }
- catch (Exception ex)
- {
- Logger.Error("Registry persist error", ex);
- }
- }
-
- private static void PersistDataKey(RegistryKey parentKey, SpDataKey data, string prefix)
- {
- foreach (var kv in data.StringValues)
- {
- try { parentKey.SetValue(kv.Key, kv.Value, RegistryValueKind.String); }
- catch { }
- }
-
- foreach (var kv in data.DwordValues)
- {
- try { parentKey.SetValue(kv.Key, kv.Value, RegistryValueKind.DWord); }
- catch { }
- }
-
- foreach (var kv in data.SubKeys)
- {
- try
- {
- using var subKey = parentKey.CreateSubKey(kv.Key);
- if (subKey != null)
- PersistDataKey(subKey, kv.Value, "");
- }
- catch { }
- }
- }
-
- private static void StoreCredentials(SpDataKey config, ITtsCredentials credentials)
- {
- switch (credentials)
- {
- case AzureCredentials azure:
- config.SetStringValue("ApiKey", azure.SubscriptionKey);
- config.SetStringValue("Region", azure.Region);
- break;
- case OpenAICredentials openai:
- config.SetStringValue("ApiKey", openai.ApiKey);
- break;
- case ElevenLabsCredentials eleven:
- config.SetStringValue("ApiKey", eleven.ApiKey);
- break;
- case GoogleCredentials google:
- config.SetStringValue("ApiKey", google.ApiKey);
- break;
- case PollyCredentials polly:
- config.SetStringValue("ApiKey", polly.AccessKeyId);
- config.SetStringValue("SecretKey", polly.SecretAccessKey);
- config.SetStringValue("Region", polly.Region);
- break;
- case SherpaOnnxCredentials sherpa:
- if (sherpa.ModelPath != null) config.SetStringValue("ModelPath", sherpa.ModelPath);
- if (sherpa.ModelId != null) config.SetStringValue("ModelId", sherpa.ModelId);
- break;
- }
- }
-
- private static ushort LocaleToLangId(string locale)
- {
- return locale.ToLowerInvariant() switch
- {
- "en-us" => 0x0409,
- "en-gb" => 0x0809,
- "zh-cn" => 0x0804,
- "zh-tw" => 0x0404,
- "ja-jp" => 0x0411,
- "ko-kr" => 0x0412,
- "de-de" => 0x0407,
- "fr-fr" => 0x040C,
- "es-es" => 0x0C0A,
- "it-it" => 0x0410,
- "pt-br" => 0x0416,
- "ru-ru" => 0x0419,
- _ => 0x0409,
- };
- }
-
- public int Next(uint celt, out ISpObjectToken pelt, out uint pceltFetched)
- {
- pelt = null!;
- pceltFetched = 0;
-
- try
- {
- if (_currentPos >= _tokens.Count)
- return SapiConstants.S_FALSE;
-
- int count = (int)Math.Min(celt, (uint)(_tokens.Count - _currentPos));
- if (count == 0)
- return SapiConstants.S_FALSE;
-
- if (celt == 1)
- {
- pelt = _tokens[_currentPos];
- _currentPos++;
- pceltFetched = 1;
- return SapiConstants.S_OK;
- }
-
- throw new NotImplementedException("Batch Next not implemented");
- }
- catch (Exception ex)
- {
- Logger.Error("Next error", ex);
- return SapiConstants.E_FAIL;
- }
- }
-
- public int Skip(uint celt)
- {
- _currentPos += (int)celt;
- if (_currentPos > _tokens.Count)
- _currentPos = _tokens.Count;
- return _currentPos < _tokens.Count ? SapiConstants.S_OK : SapiConstants.S_FALSE;
- }
-
- public int Reset()
- {
- _currentPos = 0;
- return SapiConstants.S_OK;
- }
-
- public int Clone(out IEnumSpObjectTokens ppEnum)
- {
- ppEnum = new VoiceTokenEnumerator(_tokens, _currentPos);
- return SapiConstants.S_OK;
- }
-
- public int Item(uint Index, out ISpObjectToken ppToken)
- {
- if (Index >= _tokens.Count)
- {
- ppToken = null!;
- return SapiConstants.E_INVALIDARG;
- }
- ppToken = _tokens[(int)Index];
- return SapiConstants.S_OK;
- }
-
- public int GetCount(out uint pCount)
- {
- pCount = (uint)_tokens.Count;
- return SapiConstants.S_OK;
- }
-}
diff --git a/VoiceGardenSAPIAdapter/GenericHttpTts.cpp b/VoiceGardenSAPIAdapter/GenericHttpTts.cpp
deleted file mode 100644
index e15e8ef..0000000
--- a/VoiceGardenSAPIAdapter/GenericHttpTts.cpp
+++ /dev/null
@@ -1,175 +0,0 @@
-#include "pch.h"
-#include "GenericHttpTts.h"
-#include "NetUtils.h"
-#include "Mp3Decoder.h"
-#include "Logger.h"
-
-GenericHttpTts::GenericHttpTts() = default;
-GenericHttpTts::~GenericHttpTts() = default;
-
-void GenericHttpTts::SetEngine(const std::string& engineType, const std::string& key,
- const std::string& voice, const std::string& region)
-{
- m_engineType = engineType;
- m_key = key;
- m_voice = voice;
- m_region = region;
-}
-
-std::string GenericHttpTts::JsonEscape(const std::string& s)
-{
- std::string out;
- out.reserve(s.size() + 8);
- for (char c : s)
- {
- switch (c)
- {
- case '"': out += "\\\""; break;
- case '\\': out += "\\\\"; break;
- case '\n': out += "\\n"; break;
- case '\r': out += "\\r"; break;
- case '\t': out += "\\t"; break;
- default:
- if (static_cast(c) < 0x20)
- {
- char buf[8];
- snprintf(buf, sizeof(buf), "\\u%04x", c);
- out += buf;
- }
- else
- out += c;
- }
- }
- return out;
-}
-
-GenericHttpTts::EngineRequest GenericHttpTts::BuildRequest(const std::string& text) const
-{
- EngineRequest req;
- std::string escapedText = JsonEscape(text);
-
- if (m_engineType == "OpenAI")
- {
- req.url = "https://api.openai.com/v1/audio/speech";
- req.contentType = "application/json";
- req.headers = "Authorization: Bearer " + m_key + "\r\n";
- req.body = "{\"model\":\"tts-1\",\"input\":\"" + escapedText +
- "\",\"voice\":\"" + m_voice + "\",\"response_format\":\"mp3\"}";
- }
- else if (m_engineType == "ElevenLabs")
- {
- req.url = "https://api.elevenlabs.io/v1/text-to-speech/" + m_voice;
- req.contentType = "application/json";
- req.headers = "xi-api-key: " + m_key + "\r\n";
- req.body = "{\"text\":\"" + escapedText +
- "\",\"model_id\":\"eleven_multilingual_v2\","
- "\"voice_settings\":{\"stability\":0.5,\"similarity_boost\":0.75}}";
- }
- else if (m_engineType == "Google")
- {
- req.url = "https://texttospeech.googleapis.com/v1/text:synthesize";
- req.contentType = "application/json";
- req.headers = "x-goog-api-key: " + m_key + "\r\n";
- req.body = "{\"input\":{\"text\":\"" + escapedText + "\"},"
- "\"voice\":{\"languageCode\":\"en-US\",\"name\":\"" + m_voice + "\"},"
- "\"audioConfig\":{\"audioEncoding\":\"MP3\",\"speakingRate\":1.0}}";
- req.isBase64Response = true;
- }
- else if (m_engineType == "Cartesia")
- {
- req.url = "https://api.cartesia.ai/tts/bytes";
- req.contentType = "application/json";
- req.headers = "X-API-Key: " + m_key + "\r\n";
- req.body = "{\"transcript\":\"" + escapedText +
- "\",\"voice_id\":\"" + m_voice + "\","
- "\"output_format\":{\"container\":\"raw\",\"encoding\":\"pcm_s16le\",\"sample_rate\":24000}}";
- }
- else if (m_engineType == "Deepgram")
- {
- req.url = "https://api.deepgram.com/v1/speak?model=" + m_voice;
- req.contentType = "application/json";
- req.headers = "Authorization: Token " + m_key + "\r\n";
- req.body = "{\"text\":\"" + escapedText + "\"}";
- }
- else
- {
- throw std::invalid_argument("Unsupported engine type: " + m_engineType);
- }
-
- return req;
-}
-
-void GenericHttpTts::Speak(const std::string& text, AudioCallback audioCallback)
-{
- m_abort = false;
-
- auto req = BuildRequest(text);
-
- LogInfo("HTTP TTS: {} synthesizing {} chars", m_engineType, text.size());
-
- // Make HTTP POST
- auto audioData = PostToBytes(req.url, req.body, req.contentType, req.headers);
-
- if (m_abort) return;
-
- if (audioData.empty())
- throw std::runtime_error("HTTP TTS: empty response from " + m_engineType);
-
- LogInfo("HTTP TTS: received {} bytes", audioData.size());
-
- // Handle Google's base64-in-JSON response
- if (req.isBase64Response)
- {
- // Parse JSON to extract audioContent
- std::string json(audioData.begin(), audioData.end());
- size_t pos = json.find("\"audioContent\"");
- if (pos == std::string::npos)
- throw std::runtime_error("HTTP TTS: no audioContent in Google response");
- pos = json.find('"', pos + 14) + 1;
- size_t end = json.find('"', pos);
- std::string b64 = json.substr(pos, end - pos);
-
- // Decode base64
- static const std::string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
- std::vector decoded;
- decoded.reserve(b64.size() * 3 / 4);
- int val = 0, bits = 0;
- for (char c : b64)
- {
- if (c == '=' || c == '\n' || c == '\r' || c == ' ') break;
- auto p = chars.find(c);
- if (p == std::string::npos) continue;
- val = (val << 6) | static_cast(p);
- bits += 6;
- if (bits >= 8)
- {
- bits -= 8;
- decoded.push_back(static_cast((val >> bits) & 0xFF));
- }
- }
- audioData = std::move(decoded);
- LogInfo("HTTP TTS: decoded base64 to {} bytes", audioData.size());
- }
-
- // Check if raw PCM (Cartesia) or needs MP3 decode
- bool isPcm = (m_engineType == "Cartesia");
-
- if (isPcm)
- {
- // Raw 16-bit PCM — deliver directly
- if (!audioData.empty())
- audioCallback(audioData.data(), static_cast(audioData.size()));
- }
- else
- {
- // MP3 — decode to PCM
- Mp3Decoder decoder;
- auto& waveFormat = decoder.GetWaveFormat();
- decoder.Convert(audioData.data(), static_cast(audioData.size()),
- [&audioCallback, &waveFormat](const uint8_t* data, uint32_t len) {
- audioCallback(data, len);
- });
- }
-
- LogInfo("HTTP TTS: synthesis complete");
-}
diff --git a/VoiceGardenSAPIAdapter/GenericHttpTts.h b/VoiceGardenSAPIAdapter/GenericHttpTts.h
deleted file mode 100644
index 2ee91c4..0000000
--- a/VoiceGardenSAPIAdapter/GenericHttpTts.h
+++ /dev/null
@@ -1,47 +0,0 @@
-#pragma once
-#include
-#include
-#include
-#include
-
-// Generic HTTP-based TTS synthesis for cloud engines.
-// Supports OpenAI, ElevenLabs, Google, Cartesia, Deepgram via HTTP POST.
-// Audio is decoded from MP3 to PCM and delivered via callback.
-
-class GenericHttpTts
-{
-public:
- using AudioCallback = std::function;
-
- GenericHttpTts();
- ~GenericHttpTts();
-
- // Configure for a specific engine
- void SetEngine(const std::string& engineType, const std::string& key,
- const std::string& voice, const std::string& region = {});
-
- // Synthesize text to audio, delivering PCM via callback
- void Speak(const std::string& text, AudioCallback audioCallback);
-
- // Cancel ongoing synthesis
- void Stop() { m_abort = true; }
-
-private:
- struct EngineRequest
- {
- std::string url;
- std::string body;
- std::string contentType;
- std::string headers;
- bool isBase64Response = false;
- };
-
- EngineRequest BuildRequest(const std::string& text) const;
- static std::string JsonEscape(const std::string& s);
-
- std::string m_engineType;
- std::string m_key;
- std::string m_voice;
- std::string m_region;
- std::atomic m_abort{false};
-};
diff --git a/VoiceGardenSAPIAdapter/Mp3Decoder.cpp b/VoiceGardenSAPIAdapter/Mp3Decoder.cpp
deleted file mode 100644
index 97709f5..0000000
--- a/VoiceGardenSAPIAdapter/Mp3Decoder.cpp
+++ /dev/null
@@ -1,144 +0,0 @@
-#include "pch.h"
-#include "Mp3Decoder.h"
-#pragma comment (lib, "msacm32.lib")
-#pragma comment (lib, "winmm.lib")
-
-static constexpr WORD BitRates[2][3][15] = // unit: kbps
-{
- { // Version 1
- { // Layer 1
- 0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448
- },
- { // Layer 2
- 0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384
- },
- { // Layer 3
- 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320
- }
- },
- { // Version 2
- { // Layer 1
- 0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256
- },
- { // Layer 2
- 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160
- },
- { // Layer 3 (same as 2)
- 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160
- }
- }
-};
-static constexpr WORD SampleRates[4][3] =
-{
- { 11025, 12000, 8000 }, // MPEG 2.5
- {},
- { 22050, 24000, 16000 }, // MPEG 2
- { 44100, 48000, 32000 }, // MPEG 1
-};
-static constexpr WORD SamplesPerFrame[2][3] =
-{
- { 384, 1152, 1152 }, // MPEG 1
- { 384, 1152, 576 } // Other
-};
-
-void Mp3Decoder::Init(const BYTE* pMp3Chunk, DWORD cbChunkSize)
-{
- if (!pMp3Chunk || cbChunkSize == 0)
- return; // Ignore without initializing
-
- /*
- * MP3 frame format (4 bytes): AAAAAAAA AAABBCCD EEEEFFGH IIJJKLMM
- * where:
- * A: sync bits, all 1's
- * B: MPEG version
- * C: layer
- * D: protection
- * E: bitrate index
- * F: sample rate index
- * G: padding
- * H: private
- * I: channel mode
- * J: mode extension
- * K: copyright
- * L: original
- * M: emphasis
- * Reference: https://id3lib.sourceforge.net/id3/mp3frame.html
- */
-
- const BYTE* pFrame = pMp3Chunk;
- // If the first 11 bits are not all 1's (sync bits)
- while (!(pFrame[0] == 0xFF && (pFrame[1] & 0b11100000) == 0b11100000))
- {
- // Look for next possible header position
- pFrame = static_cast(memchr(pFrame + 1, 0xFF, cbChunkSize - (pFrame - pMp3Chunk) - 1));
- if (!pFrame) // No valid MP3 frame header found
- throw std::system_error(ACMERR_NOTPOSSIBLE, mci_category());
- }
-
- int mpegVersion = (pFrame[1] & 0b00011000) >> 3; // 0: MPEG 2.5; 2: MPEG 2; 3: MPEG 1
- int layer = (pFrame[1] & 0b00000110) >> 1; // 1: Layer 3; 2: Layer 2; 3: Layer 1
- int bitRateIndex = (pFrame[2] & 0b11110000) >> 4;
- int sampleRateIndex = (pFrame[2] & 0b00001100) >> 2;
- int padding = (pFrame[2] & 0b00000010) >> 1;
- int channelMode = (pFrame[3] & 0b11000000) >> 6; // 0: Stereo; 1: Joint stereo; 2: Dual channel (2 mono); 3: Single channel (mono)
- int versionIndex = mpegVersion == 3 ? 0 : 1;
- int layerIndex = 3 - layer;
-
- if (mpegVersion == 1 || layer == 0 || bitRateIndex == 0 || bitRateIndex == 15 || sampleRateIndex == 3)
- throw std::system_error(ACMERR_NOTPOSSIBLE, mci_category());
-
- DWORD Mp3BitRate = BitRates[versionIndex][layerIndex][bitRateIndex] * 1000;
- DWORD SamplesPerSec = SampleRates[mpegVersion][sampleRateIndex];
- WORD FrameLength = (WORD)(SamplesPerFrame[versionIndex][layerIndex] * (Mp3BitRate / CHAR_BIT) / SamplesPerSec + padding);
- if (layerIndex == 0) // For Layer 1, this is needed. See: https://stackoverflow.com/questions/72416908/mp3-exact-frame-size-calculation
- FrameLength *= 4;
-
- constexpr WORD BitsPerSample = 16;
- WORD Channels = channelMode == 3 ? 1 : 2;
-
- MPEGLAYER3WAVEFORMAT mp3fmt =
- {
- .wfx = {
- .wFormatTag = WAVE_FORMAT_MPEGLAYER3,
- .nChannels = Channels,
- .nSamplesPerSec = SamplesPerSec,
- .nAvgBytesPerSec = Mp3BitRate / CHAR_BIT,
- .nBlockAlign = 1,
- .wBitsPerSample = 0,
- .cbSize = MPEGLAYER3_WFX_EXTRA_BYTES
- },
- .wID = MPEGLAYER3_ID_MPEG,
- .fdwFlags = MPEGLAYER3_FLAG_PADDING_ISO,
- .nBlockSize = FrameLength,
- .nFramesPerBlock = 1,
- .nCodecDelay = 0
- };
-
- m_wavefmt =
- {
- .wFormatTag = WAVE_FORMAT_PCM,
- .nChannels = Channels,
- .nSamplesPerSec = SamplesPerSec,
- .nAvgBytesPerSec = SamplesPerSec * Channels * (BitsPerSample / CHAR_BIT),
- .nBlockAlign = (WORD)(Channels * (BitsPerSample / CHAR_BIT)),
- .wBitsPerSample = BitsPerSample,
- .cbSize = 0
- };
-
- MMRESULT mmr = acmStreamOpen(&m_hAcm, nullptr, &mp3fmt.wfx, &m_wavefmt, nullptr, 0, 0, 0);
- if (mmr) throw std::system_error(mmr, mci_category());
-
- m_cbMp3Buf = cbChunkSize;
- m_pMp3Buf = std::make_unique_for_overwrite(m_cbMp3Buf);
-
- mmr = acmStreamSize(m_hAcm, m_cbMp3Buf, &m_cbWavBuf, ACM_STREAMSIZEF_SOURCE);
- if (mmr) throw std::system_error(mmr, mci_category());
- m_pWavBuf = std::make_unique_for_overwrite(m_cbWavBuf);
-
- m_header.pbSrc = m_pMp3Buf.get();
- m_header.cbSrcLength = m_cbMp3Buf;
- m_header.pbDst = m_pWavBuf.get();
- m_header.cbDstLength = m_cbWavBuf;
- mmr = acmStreamPrepareHeader(m_hAcm, &m_header, 0);
- if (mmr) throw std::system_error(mmr, mci_category());
-}
\ No newline at end of file
diff --git a/VoiceGardenSAPIAdapter/Mp3Decoder.h b/VoiceGardenSAPIAdapter/Mp3Decoder.h
deleted file mode 100644
index 068544e..0000000
--- a/VoiceGardenSAPIAdapter/Mp3Decoder.h
+++ /dev/null
@@ -1,116 +0,0 @@
-#pragma once
-#include
-#include
-#include "wrappers.h"
-
-class Mp3Decoder
-{
-private:
- struct AcmStreamCloser
- {
- void operator()(HACMSTREAM hAcm) const { acmStreamClose(hAcm, 0); }
- };
- std::unique_ptr m_pMp3Buf = nullptr, m_pWavBuf = nullptr;
- HandleWrapper m_hAcm = nullptr;
- ACMSTREAMHEADER m_header = { sizeof(ACMSTREAMHEADER) };
- DWORD m_cbMp3Buf = 0, m_cbWavBuf = 0, m_cbMp3Leftover = 0;
- WAVEFORMATEX m_wavefmt = {};
-
- void Init(const BYTE* pMp3Chunk, DWORD cbChunkSize);
- void UnprepareHeader()
- {
- if (m_header.fdwStatus & ACMSTREAMHEADER_STATUSF_PREPARED)
- {
- // Restore the original (maximum) buffer sizes in headers
- m_header.cbSrcLength = m_cbMp3Buf;
- m_header.cbDstLength = m_cbWavBuf;
- acmStreamUnprepareHeader(m_hAcm, &m_header, 0);
- }
- }
-
-public:
- Mp3Decoder() noexcept {}
- ~Mp3Decoder() noexcept
- {
- UnprepareHeader();
- // Other members will be cleaned up by their destructors
- }
-
- // Copying not permitted
- Mp3Decoder(const Mp3Decoder&) = delete;
- Mp3Decoder& operator=(const Mp3Decoder&) = delete;
-
- void Reset()
- {
- UnprepareHeader();
- m_hAcm.Close();
- m_pMp3Buf = nullptr;
- m_pWavBuf = nullptr;
- m_cbMp3Buf = 0;
- m_cbWavBuf = 0;
- m_cbMp3Leftover = 0;
- }
-
- template requires std::invocable
- void Convert(const BYTE* pMp3Chunk, size_t cbChunkSize, Func&& writeWavCallback);
-
- const WAVEFORMATEX& GetWaveFormat() const noexcept { return m_wavefmt; }
-};
-
-class mci_category_impl : public std::error_category
-{
-public:
- const char* name() const noexcept override { return "mci"; }
- std::string message(int ev) const override
- {
- switch (ev)
- {
- case ACMERR_NOTPOSSIBLE:
- return "ACM decoder cannot decode MP3 audio";
- case ACMERR_BUSY:
- return "ACMERR_BUSY";
- case ACMERR_UNPREPARED:
- return "ACMERR_UNPREPARED";
- case ACMERR_CANCELED:
- return "ACMERR_CANCELED";
- }
-
- char msg[256];
- mciGetErrorStringA(ev, msg, sizeof msg);
- return msg;
- }
-};
-
-inline const mci_category_impl& mci_category()
-{
- static constexpr mci_category_impl impl;
- return impl;
-}
-
-template requires std::invocable
-void Mp3Decoder::Convert(const BYTE* pMp3Chunk, size_t cbChunkSize, Func&& writeWavCallback)
-{
- if (!m_pMp3Buf)
- Init(pMp3Chunk, (DWORD)std::min(cbChunkSize, std::numeric_limits::max()));
-
- while (cbChunkSize > 0)
- {
- DWORD cbMp3BufSpace = m_cbMp3Buf - m_cbMp3Leftover;
- DWORD cbMp3Copy = (DWORD)std::min(cbMp3BufSpace, cbChunkSize);
- m_header.cbSrcLength = m_cbMp3Leftover + cbMp3Copy; // Change header to indicate size
- memcpy(m_pMp3Buf.get() + m_cbMp3Leftover, pMp3Chunk, cbMp3Copy); // Append new data after leftover
- pMp3Chunk += cbMp3Copy;
- cbChunkSize -= cbMp3Copy;
-
- MMRESULT mmr = acmStreamConvert(m_hAcm, &m_header, ACM_STREAMCONVERTF_BLOCKALIGN);
- if (mmr) throw std::system_error(mmr, mci_category());
-
- writeWavCallback(m_pWavBuf.get(), m_header.cbDstLengthUsed);
-
- // Some data may not be processed. Save the leftover for next conversion
- m_cbMp3Leftover = m_header.cbSrcLength - m_header.cbSrcLengthUsed;
- // Copy the leftover part to the beginning of the buffer
- if (m_cbMp3Leftover > 0)
- memcpy(m_pMp3Buf.get(), m_pMp3Buf.get() + m_header.cbSrcLengthUsed, m_cbMp3Leftover);
- }
-}
\ No newline at end of file
diff --git a/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.cpp b/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.cpp
new file mode 100644
index 0000000..a82cfd9
--- /dev/null
+++ b/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.cpp
@@ -0,0 +1,155 @@
+#include "pch.h"
+#include "RustTtsEngine.h"
+#include
+
+namespace RustTts {
+
+Engine::Engine() = default;
+
+Engine::~Engine() {
+ Destroy();
+}
+
+bool Engine::Create(const std::string& engineId, const std::string& credentialsJson) {
+ auto& loader = Loader::Instance();
+ if (!loader.IsLoaded()) {
+ spdlog::warn("RustTts::Engine::Create: tts_wrapper.dll not loaded");
+ return false;
+ }
+
+ m_ctx = loader.create(engineId.c_str(),
+ credentialsJson.empty() ? nullptr : credentialsJson.c_str());
+ if (!m_ctx) {
+ const char* err = loader.getLastError(nullptr);
+ spdlog::warn("RustTts::Engine::Create failed for '{}': {}", engineId,
+ err ? err : "(unknown)");
+ return false;
+ }
+
+ RegisterCallbacks();
+ spdlog::info("RustTts::Engine::Create: engine '{}' created", engineId);
+ return true;
+}
+
+void Engine::Destroy() {
+ if (m_ctx) {
+ auto& loader = Loader::Instance();
+ if (loader.IsLoaded()) {
+ loader.destroy(m_ctx);
+ }
+ m_ctx = nullptr;
+ }
+}
+
+bool Engine::Speak(const std::string& text) {
+ if (!m_ctx) return false;
+ auto& loader = Loader::Instance();
+ int32_t rc = loader.speak(m_ctx, text.c_str());
+ if (rc != 0) {
+ const char* err = loader.getLastError(m_ctx);
+ spdlog::warn("RustTts::Engine::Speak failed: {}", err ? err : "(unknown)");
+ }
+ return rc == 0;
+}
+
+bool Engine::SpeakSsml(const std::string& ssml) {
+ if (!m_ctx) return false;
+ auto& loader = Loader::Instance();
+ int32_t rc = loader.speakSsml(m_ctx, ssml.c_str());
+ if (rc != 0) {
+ const char* err = loader.getLastError(m_ctx);
+ spdlog::warn("RustTts::Engine::SpeakSsml failed: {}", err ? err : "(unknown)");
+ }
+ return rc == 0;
+}
+
+void Engine::Stop() {
+ if (!m_ctx) return;
+ auto& loader = Loader::Instance();
+ loader.stop(m_ctx);
+}
+
+void Engine::SetVoice(const std::string& voiceId) {
+ if (!m_ctx) return;
+ Loader::Instance().setVoice(m_ctx, voiceId.c_str());
+}
+
+void Engine::SetRate(float rate) {
+ if (!m_ctx) return;
+ Loader::Instance().setRate(m_ctx, rate);
+}
+
+void Engine::SetPitch(float pitch) {
+ if (!m_ctx) return;
+ Loader::Instance().setPitch(m_ctx, pitch);
+}
+
+void Engine::SetVolume(float volume) {
+ if (!m_ctx) return;
+ Loader::Instance().setVolume(m_ctx, volume);
+}
+
+void Engine::SetOnAudio(AudioCallback cb) {
+ m_onAudio = std::move(cb);
+}
+
+void Engine::SetOnBoundary(BoundaryCallback cb) {
+ m_onBoundary = std::move(cb);
+}
+
+void Engine::SetOnViseme(VisemeCallback cb) {
+ m_onViseme = std::move(cb);
+}
+
+void Engine::SetOnError(ErrorCallback cb) {
+ m_onError = std::move(cb);
+}
+
+std::string Engine::GetLastError() const {
+ if (!m_ctx) return {};
+ const char* err = Loader::Instance().getLastError(m_ctx);
+ return err ? std::string(err) : std::string{};
+}
+
+void Engine::RegisterCallbacks() {
+ auto& loader = Loader::Instance();
+ loader.setOnAudio(m_ctx, &Engine::OnAudioThunk, this);
+ loader.setOnBoundary2(m_ctx, &Engine::OnBoundaryThunk, this);
+ loader.setOnViseme(m_ctx, &Engine::OnVisemeThunk, this);
+ loader.setOnError(m_ctx, &Engine::OnErrorThunk, this);
+}
+
+// Static thunks — called by the Rust side during synthesis.
+// The userdata pointer is `this`, set in RegisterCallbacks().
+
+void Engine::OnAudioThunk(const uint8_t* data, uintptr_t len, void* ud) {
+ auto* self = static_cast(ud);
+ if (self && self->m_onAudio) {
+ self->m_onAudio(data, static_cast(len));
+ }
+}
+
+void Engine::OnBoundaryThunk(const char* word, int32_t charOffset,
+ int32_t charLen, float startS, float endS,
+ void* ud) {
+ auto* self = static_cast(ud);
+ if (self && self->m_onBoundary) {
+ self->m_onBoundary(word, charOffset, charLen, startS, endS);
+ }
+}
+
+void Engine::OnVisemeThunk(int32_t visemeId, float offsetS, void* ud) {
+ auto* self = static_cast(ud);
+ if (self && self->m_onViseme) {
+ self->m_onViseme(visemeId, offsetS);
+ }
+}
+
+void Engine::OnErrorThunk(const char* msg, void* ud) {
+ auto* self = static_cast(ud);
+ if (self && self->m_onError) {
+ self->m_onError(msg);
+ }
+}
+
+} // namespace RustTts
diff --git a/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.h b/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.h
new file mode 100644
index 0000000..ab530d3
--- /dev/null
+++ b/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.h
@@ -0,0 +1,90 @@
+#pragma once
+
+// RAII wrapper for a rust-tts-wrapper engine instance (tts_ctx).
+// Owns the context lifetime and marshals callbacks back to C++ lambdas.
+
+#include
+#include
+#include
+#include
+#include "RustTtsLoader.h"
+
+namespace RustTts {
+
+// Audio chunk callback: (pcmBytes, numBytes)
+using AudioCallback = std::function;
+
+// Boundary callback: (word, charOffset, charLen, startSec, endSec)
+using BoundaryCallback = std::function;
+
+// Viseme callback: (visemeId, offsetSec)
+using VisemeCallback = std::function;
+
+// Error callback: (errorMessage)
+using ErrorCallback = std::function;
+
+class Engine {
+public:
+ Engine();
+ ~Engine();
+
+ Engine(const Engine&) = delete;
+ Engine& operator=(const Engine&) = delete;
+
+ // Create an engine instance. Returns true on success.
+ // engineId: "openai", "google", "elevenlabs", "azure", "edge", "cartesia", etc.
+ // credentialsJson: JSON string with API keys, or empty for credential-free engines.
+ bool Create(const std::string& engineId, const std::string& credentialsJson);
+
+ // True if the engine context is valid.
+ bool IsValid() const { return m_ctx != nullptr; }
+
+ // Destroy the engine context.
+ void Destroy();
+
+ // Speak plain text. Returns true on success.
+ bool Speak(const std::string& text);
+
+ // Speak pre-built SSML. Returns true on success.
+ bool SpeakSsml(const std::string& ssml);
+
+ // Stop in-progress speech.
+ void Stop();
+
+ // Set voice, rate (1.0=normal), pitch (1.0=normal), volume (1.0=normal).
+ void SetVoice(const std::string& voiceId);
+ void SetRate(float rate);
+ void SetPitch(float pitch);
+ void SetVolume(float volume);
+
+ // Register callbacks. The engine stores these and calls them during Speak().
+ void SetOnAudio(AudioCallback cb);
+ void SetOnBoundary(BoundaryCallback cb);
+ void SetOnViseme(VisemeCallback cb);
+ void SetOnError(ErrorCallback cb);
+
+ // Get the last error message from the Rust side.
+ std::string GetLastError() const;
+
+private:
+ tts_ctx* m_ctx = nullptr;
+
+ // Callbacks — stored as members so they live as long as the engine.
+ AudioCallback m_onAudio;
+ BoundaryCallback m_onBoundary;
+ VisemeCallback m_onViseme;
+ ErrorCallback m_onError;
+
+ // Register the static thunk callbacks with the Rust side.
+ void RegisterCallbacks();
+
+ // Static thunks that route to the instance via userdata.
+ static void OnAudioThunk(const uint8_t* data, uintptr_t len, void* ud);
+ static void OnBoundaryThunk(const char* word, int32_t charOffset,
+ int32_t charLen, float startS, float endS,
+ void* ud);
+ static void OnVisemeThunk(int32_t visemeId, float offsetS, void* ud);
+ static void OnErrorThunk(const char* msg, void* ud);
+};
+
+} // namespace RustTts
diff --git a/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.cpp b/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.cpp
new file mode 100644
index 0000000..c71f2fa
--- /dev/null
+++ b/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.cpp
@@ -0,0 +1,115 @@
+#include "pch.h"
+#include "RustTtsLoader.h"
+#include
+
+namespace RustTts {
+
+Loader& Loader::Instance() {
+ static Loader instance;
+ return instance;
+}
+
+Loader::Loader() = default;
+Loader::~Loader() {
+ if (m_hModule) {
+ FreeLibrary(m_hModule);
+ m_hModule = nullptr;
+ }
+}
+
+template
+bool Loader::GetFunc(const char* name, T& funcPtr) {
+ funcPtr = reinterpret_cast(GetProcAddress(m_hModule, name));
+ if (!funcPtr) {
+ m_lastError = std::string("Failed to get function: ") + name;
+ return false;
+ }
+ return true;
+}
+
+bool Loader::Initialize() {
+ if (m_hModule) return true; // already loaded
+
+ // Try multiple locations: exe directory, system paths
+ // The Rust DLL may be named tts_wrapper.dll or rust_tts_wrapper.dll
+ // depending on the build/source (local build vs NuGet package)
+ const char* dllNames[] = {
+ "tts_wrapper.dll",
+ "rust_tts_wrapper.dll",
+ };
+
+ // Search alongside the adapter DLL itself
+ wchar_t modulePath[MAX_PATH] = {};
+ GetModuleFileNameW(GetModuleHandleW(L"VoiceGardenSAPIAdapter"), modulePath, MAX_PATH);
+ std::filesystem::path dir = std::filesystem::path(modulePath).parent_path();
+
+ // Try x64/x86 subdirs (MSI layout) then the same dir (flat layout)
+ std::vector searchDirs = {
+ dir,
+ dir / "x64",
+ dir / "x86",
+ };
+
+ for (const auto& searchDir : searchDirs) {
+ for (const char* dllName : dllNames) {
+ auto path = searchDir / dllName;
+ if (std::filesystem::exists(path)) {
+ m_hModule = LoadLibraryW(path.wstring().c_str());
+ if (m_hModule) {
+ spdlog::info("RustTts: loaded {}", path.string());
+ break;
+ }
+ }
+ }
+ if (m_hModule) break;
+ }
+
+ // Fallback to default search path
+ if (!m_hModule) {
+ for (const char* dllName : dllNames) {
+ m_hModule = LoadLibraryA(dllName);
+ if (m_hModule) {
+ spdlog::info("RustTts: loaded {} from system path", dllName);
+ break;
+ }
+ }
+ }
+
+ if (!m_hModule) {
+ m_lastError = "tts_wrapper.dll not found";
+ spdlog::info("RustTts: tts_wrapper.dll not found, using fallback engines");
+ return false;
+ }
+
+ // Resolve all function pointers
+ bool ok = true;
+ ok &= GetFunc("tts_create", create);
+ ok &= GetFunc("tts_destroy", destroy);
+ ok &= GetFunc("tts_speak", speak);
+ ok &= GetFunc("tts_speak_ssml", speakSsml);
+ ok &= GetFunc("tts_speak_sync", speakSync);
+ ok &= GetFunc("tts_stop", stop);
+ ok &= GetFunc("tts_set_voice", setVoice);
+ ok &= GetFunc("tts_set_rate", setRate);
+ ok &= GetFunc("tts_set_pitch", setPitch);
+ ok &= GetFunc("tts_set_volume", setVolume);
+ ok &= GetFunc("tts_set_on_audio", setOnAudio);
+ ok &= GetFunc("tts_set_on_boundary2", setOnBoundary2);
+ ok &= GetFunc("tts_set_on_viseme", setOnViseme);
+ ok &= GetFunc("tts_set_on_start", setOnStart);
+ ok &= GetFunc("tts_set_on_end", setOnEnd);
+ ok &= GetFunc("tts_set_on_error", setOnError);
+ ok &= GetFunc("tts_get_last_error", getLastError);
+
+ if (!ok) {
+ spdlog::warn("RustTts: failed to resolve all functions: {}", m_lastError);
+ FreeLibrary(m_hModule);
+ m_hModule = nullptr;
+ return false;
+ }
+
+ spdlog::info("RustTts: all function pointers resolved");
+ return true;
+}
+
+} // namespace RustTts
diff --git a/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.h b/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.h
new file mode 100644
index 0000000..79f7c23
--- /dev/null
+++ b/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.h
@@ -0,0 +1,64 @@
+#pragma once
+
+// Dynamic loading wrapper for rust-tts-wrapper (tts_wrapper.dll)
+// Mirrors SherpaOnnxDynamic.h: LoadLibrary + GetProcAddress pattern.
+// If the DLL fails to load, IsLoaded() returns false and the adapter
+// falls back to its built-in GenericHttpTts / SpeechRestAPI paths.
+
+#include
+#include
+#include
+
+// C ABI types from tts_wrapper.h
+struct tts_ctx;
+struct tts_voice;
+struct tts_engine_info;
+
+typedef void (*CAudioCb)(const uint8_t*, uintptr_t, void*);
+typedef void (*CBoundaryCb2)(const char*, int32_t, int32_t, float, float, void*);
+typedef void (*CVisemeCb)(int32_t, float, void*);
+typedef void (*CVoidCb)(void*);
+typedef void (*CErrorCb)(const char*, void*);
+
+namespace RustTts {
+
+class Loader {
+public:
+ static Loader& Instance();
+ bool Initialize();
+ bool IsLoaded() const { return m_hModule != nullptr; }
+ const std::string& GetLastError() const { return m_lastError; }
+
+ // Function pointers — resolved by GetProcAddress in Initialize()
+ tts_ctx* (*create)(const char*, const char*) = nullptr;
+ void (*destroy)(tts_ctx*) = nullptr;
+ int32_t (*speak)(tts_ctx*, const char*) = nullptr;
+ int32_t (*speakSsml)(tts_ctx*, const char*) = nullptr;
+ int32_t (*speakSync)(tts_ctx*, const char*) = nullptr;
+ void (*stop)(tts_ctx*) = nullptr;
+ void (*setVoice)(tts_ctx*, const char*) = nullptr;
+ void (*setRate)(tts_ctx*, float) = nullptr;
+ void (*setPitch)(tts_ctx*, float) = nullptr;
+ void (*setVolume)(tts_ctx*, float) = nullptr;
+ void (*setOnAudio)(tts_ctx*, CAudioCb, void*) = nullptr;
+ void (*setOnBoundary2)(tts_ctx*, CBoundaryCb2, void*) = nullptr;
+ void (*setOnViseme)(tts_ctx*, CVisemeCb, void*) = nullptr;
+ void (*setOnStart)(tts_ctx*, CVoidCb, void*) = nullptr;
+ void (*setOnEnd)(tts_ctx*, CVoidCb, void*) = nullptr;
+ void (*setOnError)(tts_ctx*, CErrorCb, void*) = nullptr;
+ const char* (*getLastError)(tts_ctx*) = nullptr;
+
+private:
+ Loader();
+ ~Loader();
+ Loader(const Loader&) = delete;
+ Loader& operator=(const Loader&) = delete;
+
+ HMODULE m_hModule = nullptr;
+ std::string m_lastError;
+
+ template
+ bool GetFunc(const char* name, T& funcPtr);
+};
+
+} // namespace RustTts
diff --git a/VoiceGardenSAPIAdapter/RustTts/tts_wrapper.h b/VoiceGardenSAPIAdapter/RustTts/tts_wrapper.h
new file mode 100644
index 0000000..958aac8
--- /dev/null
+++ b/VoiceGardenSAPIAdapter/RustTts/tts_wrapper.h
@@ -0,0 +1,386 @@
+#ifndef TTS_WRAPPER_H
+#define TTS_WRAPPER_H
+
+/* Auto-generated. Do not edit. */
+
+#include
+#include
+
+typedef struct tts_ctx tts_ctx;
+
+/**
+ * C-compatible voice descriptor returned by [`tts_get_voices`](crate::tts_get_voices).
+ */
+typedef struct tts_voice {
+ /**
+ * Voice identifier (owned C string).
+ */
+ char *id;
+ /**
+ * Voice name (owned C string).
+ */
+ char *name;
+ /**
+ * Language tag (owned C string).
+ */
+ char *language;
+ /**
+ * Gender (owned C string).
+ */
+ char *gender;
+ /**
+ * Engine identifier (owned C string).
+ */
+ char *engine;
+} tts_voice;
+
+/**
+ * Opaque context holding an engine instance and its per-instance settings.
+ */
+typedef void (*CAudioCb)(const uint8_t*, uintptr_t, void*);
+
+typedef void (*CBoundaryCb)(const char*, float, float, void*);
+
+typedef void (*CBoundaryCb2)(const char*, int32_t, int32_t, float, float, void*);
+
+typedef void (*CVisemeCb)(int32_t, float, void*);
+
+typedef void (*CVoidCb)(void*);
+
+typedef void (*CErrorCb)(const char*, void*);
+
+/**
+ * C-compatible engine descriptor returned by [`tts_get_engines`](crate::tts_get_engines).
+ */
+typedef struct tts_engine_info {
+ /**
+ * Engine identifier (owned C string).
+ */
+ char *id;
+ /**
+ * Engine name (owned C string).
+ */
+ char *name;
+ /**
+ * Whether credentials are required.
+ */
+ bool needs_credentials;
+ /**
+ * JSON array of credential key names (owned C string).
+ */
+ char *credential_keys_json;
+} tts_engine_info;
+
+#ifdef __cplusplus
+extern "C" {
+#endif // __cplusplus
+
+/**
+ * Create a new TTS engine instance.
+ *
+ * Returns an opaque context pointer on success, or null on failure.
+ * Call [`tts_get_last_error`] to retrieve the error message on failure.
+ *
+ * # Safety
+ *
+ * `engine_id` must be a valid null-terminated C string.
+ * `credentials_json` may be null or a valid null-terminated JSON string.
+ */
+struct tts_ctx *tts_create(const char *engine_id, const char *credentials_json);
+
+/**
+ * Destroy a TTS context and free all associated resources.
+ *
+ * Attempts to stop any in-progress speech before dropping the engine so the
+ * underlying resources (speech-dispatcher connection, COM objects, etc.) get
+ * a chance to clean up
+ *
+ * # Safety
+ *
+ * `ctx` must be a pointer previously returned by [`tts_create`],
+ * or null (no-op).
+ */
+void tts_destroy(struct tts_ctx *ctx);
+
+/**
+ * Speak `text` asynchronously using the engine in `ctx`.
+ *
+ * Returns 0 on success, -1 on failure.
+ *
+ * # Safety
+ *
+ * `ctx` must be a valid pointer from [`tts_create`].
+ * `text` must be a valid null-terminated C string.
+ */
+int32_t tts_speak(struct tts_ctx *ctx, const char *text);
+
+/**
+ * Speak pre-built SSML using the engine in `ctx`.
+ *
+ * The SSML is passed directly to the engine without SpeechMarkdown
+ * conversion or rate/pitch/volume wrapping. Callers are responsible
+ * for embedding all prosody in the SSML.
+ *
+ * Returns 0 on success, -1 on failure.
+ *
+ * # Safety
+ *
+ * `ctx` must be a valid pointer from [`tts_create`].
+ * `ssml` must be a valid null-terminated C string.
+ */
+int32_t tts_speak_ssml(struct tts_ctx *ctx, const char *ssml);
+
+/**
+ * Speak `text` synchronously (blocks until complete).
+ *
+ * Returns 0 on success, -1 on failure.
+ *
+ * # Safety
+ *
+ * `ctx` must be a valid pointer from [`tts_create`].
+ * `text` must be a valid null-terminated C string.
+ */
+int32_t tts_speak_sync(struct tts_ctx *ctx, const char *text);
+
+/**
+ * Stop any in-progress speech.
+ *
+ * # Safety
+ *
+ * `ctx` must be a valid pointer from [`tts_create`].
+ */
+void tts_stop(struct tts_ctx *ctx);
+
+/**
+ * Retrieve the list of available voices for the engine.
+ *
+ * On success, writes a heap-allocated array to `*out_voices` and its length
+ * to `*out_count`. Caller must free with [`tts_free_voices`].
+ *
+ * Returns 0 on success, -1 on failure.
+ *
+ * # Safety
+ *
+ * `ctx` must be valid. `out_voices` and `out_count` must be non-null.
+ */
+int32_t tts_get_voices(struct tts_ctx *ctx, struct tts_voice **out_voices, int32_t *out_count);
+
+/**
+ * Free a voice array previously returned by [`tts_get_voices`].
+ *
+ * # Safety
+ *
+ * `voices` must be a pointer from `tts_get_voices` with the matching `count`.
+ */
+void tts_free_voices(struct tts_voice *voices, int32_t count);
+
+/**
+ * Set the voice for subsequent speak calls.
+ *
+ * # Safety
+ *
+ * `ctx` must be valid. `voice_id` must be a valid null-terminated C string.
+ */
+void tts_set_voice(struct tts_ctx *ctx, const char *voice_id);
+
+/**
+ * Set the speech rate (1.0 = normal).
+ *
+ * # Safety
+ *
+ * `ctx` must be valid.
+ */
+void tts_set_rate(struct tts_ctx *ctx, float rate);
+
+/**
+ * Set the speech pitch (1.0 = normal).
+ *
+ * # Safety
+ *
+ * `ctx` must be valid.
+ */
+void tts_set_pitch(struct tts_ctx *ctx, float pitch);
+
+/**
+ * Set the speech volume (1.0 = normal).
+ *
+ * # Safety
+ *
+ * `ctx` must be valid.
+ */
+void tts_set_volume(struct tts_ctx *ctx, float volume);
+
+/**
+ * Set the callback for streaming audio chunks.
+ *
+ * # Safety
+ * `ctx` must be valid.
+ */
+void tts_set_on_audio(struct tts_ctx *ctx, CAudioCb cb, void *userdata);
+
+/**
+ * Set the callback for word boundary events.
+ *
+ * # Safety
+ * `ctx` must be valid.
+ */
+void tts_set_on_boundary(struct tts_ctx *ctx, CBoundaryCb cb, void *userdata);
+
+/**
+ * Extended boundary callback with source-text char offset and length.
+ * cb(word, char_offset, char_len, start_s, end_s, userdata)
+ *
+ * # Safety
+ * `ctx` must be valid.
+ */
+void tts_set_on_boundary2(struct tts_ctx *ctx, CBoundaryCb2 cb, void *userdata);
+
+/**
+ * Viseme callback for lip-sync / facial animation.
+ * cb(viseme_id, audio_offset_sec, userdata)
+ *
+ * # Safety
+ * `ctx` must be valid.
+ */
+void tts_set_on_viseme(struct tts_ctx *ctx, CVisemeCb cb, void *userdata);
+
+/**
+ * Set the callback fired when speech starts.
+ *
+ * # Safety
+ * `ctx` must be valid.
+ */
+void tts_set_on_start(struct tts_ctx *ctx, CVoidCb cb, void *userdata);
+
+/**
+ * Set the callback fired when speech completes successfully.
+ *
+ * # Safety
+ * `ctx` must be valid.
+ */
+void tts_set_on_end(struct tts_ctx *ctx, CVoidCb cb, void *userdata);
+
+/**
+ * Set the callback fired when speech fails.
+ *
+ * The error message is a null-terminated C string valid for the duration
+ * of the callback only.
+ *
+ * # Safety
+ * `ctx` must be valid.
+ */
+void tts_set_on_error(struct tts_ctx *ctx, CErrorCb cb, void *userdata);
+
+/**
+ * Return the number of registered engines.
+ */
+int32_t tts_get_engine_count(void);
+
+/**
+ * Get the list of available engine descriptors.
+ *
+ * On success, writes a heap-allocated array to `*out_engines` and its length
+ * to `*out_count`. Caller must free with [`tts_free_engines`].
+ *
+ * Returns 0 on success, -1 on failure.
+ *
+ * # Safety
+ *
+ * `out_engines` and `out_count` must be non-null.
+ */
+int32_t tts_get_engines(struct tts_engine_info **out_engines, int32_t *out_count);
+
+/**
+ * Free an engine info array previously returned by [`tts_get_engines`].
+ *
+ * # Safety
+ *
+ * `engines` must be a pointer from `tts_get_engines` with the matching `count`.
+ */
+void tts_free_engines(struct tts_engine_info *engines, int32_t count);
+
+/**
+ * Return the last error message as a C string, or null if none.
+ *
+ * If ctx is provided, returns the per-context error. If ctx is null,
+ * returns the global error (for tts_create failures).
+ *
+ * The returned pointer is valid until the next call to any TTS function.
+ *
+ * # Safety
+ *
+ * `ctx` may be null (returns global error), or a valid context pointer.
+ */
+const char *tts_get_last_error(struct tts_ctx *ctx);
+
+/**
+ * Pause in-progress speech.
+ *
+ * # Safety
+ * `ctx` must be valid.
+ */
+void tts_pause(struct tts_ctx *ctx);
+
+/**
+ * Resume paused speech.
+ *
+ * # Safety
+ * `ctx` must be valid.
+ */
+void tts_resume(struct tts_ctx *ctx);
+
+/**
+ * Synthesize text to audio bytes without playback.
+ * Writes a heap-allocated buffer to `*out_bytes` and its length to `*out_len`.
+ * Caller must free with [`tts_free_bytes`].
+ * Returns 0 on success, -1 on failure.
+ *
+ * # Safety
+ * `ctx` must be valid. `out_bytes` and `out_len` must be non-null.
+ */
+int32_t tts_synth_to_bytes(struct tts_ctx *ctx,
+ const char *text,
+ uint8_t **out_bytes,
+ uintptr_t *out_len);
+
+/**
+ * Free a byte buffer returned by [`tts_synth_to_bytes`].
+ *
+ * # Safety
+ * `bytes` must be from `tts_synth_to_bytes` with the matching `len`.
+ */
+void tts_free_bytes(uint8_t *bytes, uintptr_t len);
+
+extern void *avsynth_create(void);
+
+extern void avsynth_destroy(void *handle);
+
+extern void avsynth_speak(void *handle,
+ const char *text,
+ const char *voice_id,
+ float rate,
+ float pitch,
+ float volume);
+
+extern void avsynth_stop(void *handle);
+
+extern void avsynth_pause(void *handle);
+
+extern void avsynth_resume(void *handle);
+
+extern int32_t avsynth_voice_count(void *handle);
+
+extern int32_t avsynth_get_voice(void *handle,
+ int32_t index,
+ char *id_buf,
+ int32_t id_buf_len,
+ char *name_buf,
+ int32_t name_buf_len,
+ char *lang_buf,
+ int32_t lang_buf_len);
+
+#ifdef __cplusplus
+} // extern "C"
+#endif // __cplusplus
+
+#endif /* TTS_WRAPPER_H */
diff --git a/VoiceGardenSAPIAdapter/SpeechRestAPI.cpp b/VoiceGardenSAPIAdapter/SpeechRestAPI.cpp
deleted file mode 100644
index 2f7265a..0000000
--- a/VoiceGardenSAPIAdapter/SpeechRestAPI.cpp
+++ /dev/null
@@ -1,427 +0,0 @@
-#include "pch.h"
-#include "SpeechRestAPI.h"
-#include "NetUtils.h"
-#include "StrUtils.h"
-#include
-#include "Logger.h"
-#include "WSConnectionPool.h"
-
-std::unique_ptr g_pConnectionPool;
-static std::once_flag s_initOnce;
-
-static std::string MakeRandomUuid()
-{
- GUID guid;
- (void)CoCreateGuid(&guid);
- return std::format("{:08x}{:04x}{:04x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
- guid.Data1, guid.Data2, guid.Data3,
- guid.Data4[0], guid.Data4[1], guid.Data4[2], guid.Data4[3], guid.Data4[4], guid.Data4[5], guid.Data4[6], guid.Data4[7]);
-}
-
-static std::string GetTimeStamp()
-{
- SYSTEMTIME tm;
- GetSystemTime(&tm);
- return std::format("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
- tm.wYear, tm.wMonth, tm.wDay, tm.wHour, tm.wMinute, tm.wSecond, tm.wMilliseconds);
-}
-
-SpeechRestAPI::SpeechRestAPI()
-{
- std::call_once(s_initOnce, []()
- {
- if (!g_pConnectionPool)
- g_pConnectionPool = std::make_unique();
- });
-}
-
-SpeechRestAPI::~SpeechRestAPI()
-{
- m_stopSource.request_stop();
-}
-
-std::future SpeechRestAPI::SpeakAsync(const std::wstring& ssml)
-{
- m_ssml = ssml;
- m_lastWordPos = 0;
- m_lastSentencePos = 0;
- m_events = {};
- m_waveBytesWritten = 0;
- m_stopSource = {};
- m_firstDataReceived = false;
- m_allDataReceived = false;
-
- auto fut = std::async(std::launch::async, std::bind(&SpeechRestAPI::DoSpeakAsync, this));
-
- return fut;
-}
-
-void SpeechRestAPI::Stop()
-{
- m_stopSource.request_stop();
-}
-
-void SpeechRestAPI::SetSubscription(std::string key, const std::string& region)
-{
- m_key = std::move(key);
- m_voiceListUrl = std::string("https://") + region + AZURE_TTS_HOST_AFTER_REGION + AZURE_VOICE_LIST_PATH;
- m_websocketUrl = std::string("wss://") + region + AZURE_TTS_HOST_AFTER_REGION + AZURE_WEBSOCKET_PATH;
-}
-
-void SpeechRestAPI::SetWebsocketUrl(std::string key, std::string websocketUrl)
-{
- m_key = std::move(key);
- m_websocketUrl = std::move(websocketUrl);
-}
-
-void SpeechRestAPI::DoSpeakAsync()
-{
- auto conn = g_pConnectionPool->TakeConnection(m_websocketUrl, m_key, m_stopSource.get_token());
- if (!conn)
- return;
- ScopeGuard connectionCloser([&conn]()
- {
- // conn will be reset to nullptr by OnMessage when all data has been received.
- // If not, close the connection before returning.
- if (conn)
- conn->terminate({});
- });
-
- BlockingQueue queue;
-
- conn->set_message_handler([this, &conn, &queue](websocketpp::connection_hdl, WSClient::message_ptr msg)
- { OnMessage(queue, conn, msg); });
- conn->set_close_handler([this, &conn, &queue](websocketpp::connection_hdl)
- { OnClose(queue, conn); });
-
- SendRequest(conn);
-
- // does not return until all MP3 data are written
- Mp3ProcessLoop(queue, m_stopSource.get_token());
-}
-
-void SpeechRestAPI::ProcessWaveData(const WAVEFORMATEX& wfx, BYTE* waveData, uint32_t waveSize)
-{
- // Most of the time, the server will send event data before audio data.
- // But sometimes, event data may fall behind audio data.
- // So if there's no existing event at this time position,
- // we wait for at most 10ms for possible events.
-
- // Wait at most once
- bool hasWaited = false;
- for (;;)
- {
- uint64_t evtBytes;
- EventInfo evt;
-
- {
- std::unique_lock lock(m_eventsMutex);
- if (m_events.empty())
- {
- if (WordBoundaryCallback && !hasWaited)
- {
- lock.unlock();
- Sleep(10); // wait for possible events
- hasWaited = true;
- continue;
- }
- else
- break;
- }
- evtBytes = m_events.top().first * wfx.nAvgBytesPerSec / 10'000'000;
-
- // align evtBytes to wave blocks
- WORD blockAlign = wfx.nBlockAlign;
- evtBytes = evtBytes - (evtBytes % blockAlign);
-
- if (m_waveBytesWritten + waveSize <= evtBytes) // we haven't reached the next event yet
- {
- if (WordBoundaryCallback && !hasWaited)
- {
- lock.unlock();
- Sleep(10); // wait for possible events
- hasWaited = true;
- continue;
- }
- else
- break;
- }
-
- // Use const_cast so that it can be moved, as priority_queue::top() returns a const reference
- evt = std::move(const_cast(m_events.top()));
- m_events.pop();
- }
-
- // Send the audio part before this event's time point
- if (evtBytes < m_waveBytesWritten)
- LogDebug("Rest API: Events out of order");
- uint32_t waveSizeBefore = evtBytes >= m_waveBytesWritten ? (uint32_t)(evtBytes - m_waveBytesWritten) : 0;
- if (waveSizeBefore != 0)
- {
- // Writing can fail sometimes. Advance the pointer by actual written bytes. We will try again later.
- int written = AudioReceivedCallback(waveData, waveSizeBefore);
- m_waveBytesWritten += written;
- waveData += written;
- waveSize -= written;
- }
-
- // Send the event
- OnSynthEvent(evt.second);
- }
-
- // write the rest of the audio data
- if (waveSize != 0)
- {
- int written = AudioReceivedCallback(waveData, waveSize);
- m_waveBytesWritten += waveSize;
- // If the audio still couldn't be written, log it
- if ((uint32_t)written != waveSize)
- LogWarn("Rest API: {} bytes of wave data could not be written at byte position {}",
- waveSize, m_waveBytesWritten);
- }
-}
-
-void SpeechRestAPI::Mp3ProcessLoop(BlockingQueue& queue, std::stop_token token)
-{
- Mp3Decoder mp3Decoder;
-
- for (;;)
- {
- auto msg = queue.take(token);
- if (!msg.has_value())
- return;
-
- // msg is the whole message (including header) from server
- // after "Path:audio\r\n" are audio binary data
- // Note that the first 2 bytes are not part of the header string
- std::string_view mp3data = *msg;
- mp3data.remove_prefix(2);
- size_t delimPos = mp3data.find("Path:audio\r\n");
- if (delimPos == mp3data.npos) continue;
- mp3data.remove_prefix(delimPos + 12);
-
- if (!m_firstDataReceived)
- {
- LogDebug("Rest API: MP3 data received");
- m_firstDataReceived = true;
- }
-
- // Sending audio data to SAPI can block, so do this without lock
- mp3Decoder.Convert(reinterpret_cast(mp3data.data()), mp3data.size(),
- std::bind_front(&SpeechRestAPI::ProcessWaveData, this, std::ref(mp3Decoder.GetWaveFormat())));
- }
-}
-
-void SpeechRestAPI::OnClose(BlockingQueue& queue, const WSConnectionPtr& conn)
-{
- LogDebug("Rest API: Connection closed");
- if (!m_allDataReceived && !m_stopSource.stop_requested())
- {
- LogWarn("Rest API: Connection closed before all data could be received.");
- }
-
- auto code = conn->get_remote_close_code();
- if (code == websocketpp::close::status::invalid_payload)
- {
- auto msg = std::string("Payload rejected by server: ") + UTF8ToAnsi(conn->get_remote_close_reason());
- queue.fail(std::make_exception_ptr(std::runtime_error(msg)));
- }
- queue.complete();
-}
-
-// Send configuration and wait for audio data response
-void SpeechRestAPI::SendRequest(const WSConnectionPtr& conn)
-{
- m_allDataReceived = false;
-
- nlohmann::json json = {
- {"context", {
- {"synthesis", {
- {"audio", {
- {"metadataOptions", {
- {"bookmarkEnabled", (bool)BookmarkCallback},
- {"punctuationBoundaryEnabled", (bool)PunctuationBoundaryCallback},
- {"sentenceBoundaryEnabled", (bool)SentenceBoundaryCallback},
- {"wordBoundaryEnabled", (bool)WordBoundaryCallback},
- {"visemeEnabled", (bool)VisemeCallback},
- }},
- {"outputFormat", "audio-24khz-96kbitrate-mono-mp3"}
- }},
- {"language", {
- {"autoDetection", false}
- }}
- }}
- }}
- };
-
- std::string reqId = MakeRandomUuid();
-
- conn->send(
- "X-Timestamp:" + GetTimeStamp() + "\r\n"
- "Content-Type:application/json; charset=utf-8\r\n"
- "Path:speech.config\r\n\r\n"
- + json.dump(),
- websocketpp::frame::opcode::text);
-
- conn->send(
- "X-Timestamp:" + GetTimeStamp() + "\r\n"
- "X-RequestId:" + reqId + "\r\n"
- "Content-Type:application/ssml+xml\r\n"
- "Path:ssml\r\n\r\n"
- + WStringToUTF8(m_ssml),
- websocketpp::frame::opcode::text);
-}
-
-void SpeechRestAPI::OnMessage(BlockingQueue& queue, WSConnectionPtr& conn, WSClient::message_ptr msg)
-{
- try
- {
- if (msg->get_opcode() == websocketpp::frame::opcode::binary)
- {
- // If the message is binary, place this message in the queue to let the MP3 thread process it
- queue.push(std::move(msg->get_raw_payload()));
- }
- else
- {
- // If not, after "Path:xxx\r\n\r\n" are JSON texts
- std::string_view text = msg->get_payload();
- size_t pathStartPos = text.find("Path:");
- if (pathStartPos == text.npos) return;
- pathStartPos += 5;
- size_t pathEndPos = text.find("\r\n\r\n", pathStartPos);
- if (pathEndPos == text.npos) return;
-
- std::string_view path = text.substr(pathStartPos, pathEndPos - pathStartPos);
- if (path == "audio.metadata")
- {
- auto json = nlohmann::json::parse(text.substr(pathEndPos + 4));
- std::lock_guard lock(m_eventsMutex);
- for (auto& event : json.at("Metadata"))
- {
- // use audio offset ticks to sort the events
- auto& data = event.at("Data");
- auto it = data.find("Offset");
- if (it != data.end())
- m_events.emplace(it->get(), std::move(event));
- }
- }
- else if (path == "turn.end")
- {
- // Data receiving completed
- m_allDataReceived = true;
- queue.complete();
- conn.reset(); // return the connection to the pool
- }
- }
- }
- catch (...)
- {
- queue.fail(std::current_exception());
- }
-}
-
-void SpeechRestAPI::OnSynthEvent(const nlohmann::json& metadata)
-{
- std::string type = metadata.at("Type");
- auto& data = metadata.at("Data");
- uint64_t offset = data.at("Offset");
-
- if (type == "Viseme")
- {
- if (VisemeCallback)
- VisemeCallback(offset, data.at("VisemeId"));
- }
- else if (type == "WordBoundary")
- {
- auto& info = data.at("text");
- if (info.at("BoundaryType").get() == "PunctuationBoundary")
- {
- if (PunctuationBoundaryCallback)
- PunctuationBoundaryCallback(offset, (uint32_t)FindWord(info.at("Text"), m_lastWordPos), info.at("Length"));
- }
- else
- {
- if (WordBoundaryCallback)
- WordBoundaryCallback(offset, (uint32_t)FindWord(info.at("Text"), m_lastWordPos), info.at("Length"));
- }
- }
- else if (type == "SentenceBoundary")
- {
- auto& info = data.at("text");
- if (SentenceBoundaryCallback)
- SentenceBoundaryCallback(offset, (uint32_t)FindWord(info.at("Text"), m_lastSentencePos), info.at("Length"));
- }
- else if (type == "SessionEnd")
- {
- if (SessionEndCallback)
- SessionEndCallback(offset);
- }
- else if (type == "Bookmark")
- {
- if (BookmarkCallback)
- BookmarkCallback(offset, data.at("Bookmark"));
- }
-}
-
-static std::wstring XmlEscape(const std::wstring& str)
-{
- std::wstring ret;
- ret.reserve(str.size());
- LPCWSTR pEnd = str.c_str() + str.size();
-
- for (LPCWSTR pCh = str.c_str(); pCh != pEnd; pCh++)
- {
- switch (*pCh)
- {
- case '<': ret.append(L"<"); break;
- case '>': ret.append(L">"); break;
- case '&': ret.append(L"&"); break;
- case '"': ret.append(L"""); break;
- case '\'': ret.append(L"'"); break;
- default: ret.push_back(*pCh); break;
- }
- }
-
- return ret;
-}
-
-// Speech API only returns the word text and word length in a WordBoundary event,
-// so we have to calculate the text offset of the word ourselves.
-// Returned offset is in WCHARs.
-// Argument lastPos [in, out] is the variable that records the last boundary position.
-size_t SpeechRestAPI::FindWord(const std::string& utf8Word, size_t& lastPos)
-{
- // Escape XML chars, otherwise words such as "you're" will not be matched
- std::wstring word = XmlEscape(UTF8ToWString(utf8Word));
- std::wstring_view ssml = m_ssml;
- size_t startpos = lastPos;
- size_t wordPos;
- while ((wordPos = ssml.find(word, startpos)) != ssml.npos) // look for an occurrence of the word
- {
- // check if there's unmatched "<>" pair before this word
- std::wstring_view beforeWord = ssml.substr(startpos, wordPos - startpos);
- for (;;)
- {
- // look for a '<'
- size_t tagStart = beforeWord.find('<');
- if (tagStart == beforeWord.npos) // no more '<', meaning all "<>" matched or there's no "<>"
- {
- lastPos = wordPos + word.size();
- return wordPos; // exit here
- }
-
- // look for the matching '>'
- size_t tagEnd = beforeWord.find('>', tagStart + 1);
- if (tagEnd == beforeWord.npos) // no matching '>', so the word is inside a "<>" pair
- {
- break;
- }
- beforeWord.remove_prefix(tagEnd + 1); // look for the next "<>" pair
- }
-
- // Now we confirmed that the word is inside a "<>" pair
- // Skip to the next '>' and continue searching
- startpos = ssml.find('>', wordPos + word.size());
- }
- return ssml.npos;
-}
\ No newline at end of file
diff --git a/VoiceGardenSAPIAdapter/SpeechRestAPI.h b/VoiceGardenSAPIAdapter/SpeechRestAPI.h
deleted file mode 100644
index d5ba6ca..0000000
--- a/VoiceGardenSAPIAdapter/SpeechRestAPI.h
+++ /dev/null
@@ -1,84 +0,0 @@
-#pragma once
-#define ASIO_STANDALONE 1
-#include
-#include
-#include
-#include
-#include "nlohmann/json.hpp"
-#include "SpeechServiceConstants.h"
-#include "WSLogger.h"
-#include "Mp3Decoder.h"
-#include "WSConnectionPool.h"
-#include "BlockingQueue.h"
-
-
-class SpeechRestAPI
-{
-public: // Public methods
- SpeechRestAPI();
- ~SpeechRestAPI();
- std::future SpeakAsync(const std::wstring& ssml);
- void Stop();
- void SetSubscription(std::string key, const std::string& region);
- void SetWebsocketUrl(std::string key, std::string websocketUrl);
-
-public: // Event callbacks
- typedef std::function BookmarkCallbackType;
- typedef std::function BoundaryCallbackType;
- typedef std::function VisemeCallbackType;
- typedef std::function SessionEndCallbackType;
- typedef std::function AudioReceivedCallbackType;
-
- BookmarkCallbackType BookmarkCallback;
- BoundaryCallbackType WordBoundaryCallback, SentenceBoundaryCallback, PunctuationBoundaryCallback;
- VisemeCallbackType VisemeCallback;
- SessionEndCallbackType SessionEndCallback;
- AudioReceivedCallbackType AudioReceivedCallback;
-
-private:
- // Store each event's audio tick (uint64) and json "Metadata" node into a sorted queue (priority_queue)
- // so that we can dispatch the event at the correct audio time
- typedef std::pair EventInfo;
- struct EventComparer
- {
- bool operator()(const EventInfo& a, const EventInfo& b) const noexcept
- {
- // sort by audio tick value, with the smallest tick at top
- return a.first > b.first;
- }
- };
- std::priority_queue, EventComparer> m_events;
- std::mutex m_eventsMutex;
- uint64_t m_waveBytesWritten = 0;
-public:
- uint64_t GetWaveBytesWritten() const noexcept { return m_waveBytesWritten; }
-
-private:
- std::wstring_view m_ssml;
- std::string m_voiceListUrl, m_websocketUrl, m_key;
- size_t m_lastWordPos = 0, m_lastSentencePos = 0;
-
-private: // threading
-
- std::stop_source m_stopSource;
-
- // We have to queue received audio data instead of sending them directly to SAPI.
- // SAPI will block write calls before it needs more data,
- // but the remote server disconnects after a few inactive seconds
- // even if not all data are sent.
- // So we have to store all received audio data in a queue temporarily,
- // then use another thread for sending the data to SAPI.
- bool m_firstDataReceived = false;
- bool m_allDataReceived = false;
-
- void DoSpeakAsync();
- void Mp3ProcessLoop(BlockingQueue& queue, std::stop_token token);
- void ProcessWaveData(const WAVEFORMATEX& wfx, BYTE* waveData, uint32_t waveSize);
-
-private:
- void SendRequest(const WSConnectionPtr& conn);
- void OnMessage(BlockingQueue& queue, WSConnectionPtr& conn, WSClient::message_ptr msg);
- void OnClose(BlockingQueue& queue, const WSConnectionPtr& conn);
- void OnSynthEvent(const nlohmann::json& metadata);
- size_t FindWord(const std::string& utf8Word, size_t& lastPos);
-};
\ No newline at end of file
diff --git a/VoiceGardenSAPIAdapter/TTSEngine.cpp b/VoiceGardenSAPIAdapter/TTSEngine.cpp
index 7c77b92..91ef7bf 100644
--- a/VoiceGardenSAPIAdapter/TTSEngine.cpp
+++ b/VoiceGardenSAPIAdapter/TTSEngine.cpp
@@ -2,7 +2,6 @@
#include "pch.h"
#include "TTSEngine.h"
-#include "SpeechRestAPI.h"
#include "NetUtils.h"
#include "SpeechServiceConstants.h"
#include
@@ -57,31 +56,58 @@ static std::wstring ExtractSherpaPlainText(const SPVTEXTFRAG* pTextFragList)
return TrimWhitespace(out);
}
-namespace {
-std::mutex g_sherpaInitMutex;
-std::mutex g_sherpaGenerateMutex;
-std::string g_cachedSherpaKey;
-std::shared_ptr g_cachedSherpaEngine;
-
-std::string BuildSherpaEngineKey(const SherpaOnnx::ModelConfig& cfg)
+std::wstring CTTSEngine::ExtractSherpaPlainTextWithMap(const SPVTEXTFRAG* pTextFragList)
{
- std::string key = std::to_string(static_cast(cfg.modelType)) + "|";
- switch (cfg.modelType)
+ std::wstring out;
+ m_plainToSapiMap.clear();
+ m_plainToSapiMap.reserve(256);
+
+ for (auto pTextFrag = pTextFragList; pTextFrag; pTextFrag = pTextFrag->pNext)
+ {
+ if (!pTextFrag->pTextStart || pTextFrag->ulTextLen == 0)
+ continue;
+
+ if (pTextFrag->State.eAction == SPVA_Speak ||
+ pTextFrag->State.eAction == SPVA_SpellOut ||
+ pTextFrag->State.eAction == SPVA_Pronounce)
+ {
+ // Add space between fragments if needed
+ if (!out.empty() && !iswspace(out.back()))
+ {
+ out.push_back(L' ');
+ // Map the space to the previous fragment's end position in SAPI source
+ m_plainToSapiMap.push_back(
+ m_plainToSapiMap.empty() ? 0 : m_plainToSapiMap.back());
+ }
+
+ // Append text and build mapping
+ ULONG sapiSrcOffset = pTextFrag->ulTextSrcOffset;
+ for (ULONG i = 0; i < pTextFrag->ulTextLen; i++)
+ {
+ out.push_back(pTextFrag->pTextStart[i]);
+ m_plainToSapiMap.push_back(sapiSrcOffset + i);
+ }
+ }
+ }
+
+ // Trim trailing whitespace from text and map
+ while (!out.empty() && iswspace(out.back()))
{
- case SherpaOnnx::TtsModelType::Matcha:
- key += cfg.matcha.acousticModel + "|" + cfg.matcha.vocoder + "|" + cfg.matcha.tokens + "|" + cfg.matcha.dataDir;
- break;
- case SherpaOnnx::TtsModelType::Kokoro:
- key += cfg.kokoro.model + "|" + cfg.kokoro.voices + "|" + cfg.kokoro.tokens + "|" + cfg.kokoro.dataDir + "|" + cfg.kokoro.lang;
- break;
- case SherpaOnnx::TtsModelType::Vits:
- default:
- key += cfg.vits.model + "|" + cfg.vits.tokens + "|" + cfg.vits.dataDir;
- break;
+ out.pop_back();
+ if (!m_plainToSapiMap.empty())
+ m_plainToSapiMap.pop_back();
}
- key += "|" + cfg.provider + "|" + std::to_string(cfg.numThreads);
- return key;
+
+ return out;
}
+
+ULONG CTTSEngine::TranslateOffset(ULONG plainOffset) const
+{
+ if (m_plainToSapiMap.empty())
+ return plainOffset; // No mapping — identity
+ if (plainOffset >= m_plainToSapiMap.size())
+ return m_plainToSapiMap.back() + 1; // Beyond end — point to end of text
+ return m_plainToSapiMap[plainOffset];
}
// ISpObjectWithToken Implementation
@@ -104,11 +130,10 @@ STDMETHODIMP CTTSEngine::SetObjectToken(ISpObjectToken* pToken) noexcept
InitVoice();
LogInfo("TTS init: InitVoice completed");
- if (m_isSherpaOnnxVoice)
+ if (m_rustTtsUseSsml == false && m_rustTts)
{
- // Sherpa path synthesizes plain text directly and does not require
- // SAPI phone converter initialization.
- LogInfo("TTS init: skipping InitPhoneConverter for Sherpa voice");
+ // Sherpa/cloud non-Azure path synthesizes plain text directly.
+ LogInfo("TTS init: skipping InitPhoneConverter for non-SSML voice");
}
else
{
@@ -152,36 +177,31 @@ STDMETHODIMP CTTSEngine::Speak(DWORD /*dwSpeakFlags*/,
const SPVTEXTFRAG* pTextFragList,
ISpTTSEngineSite* pOutputSite) noexcept
{
- ScopeTracer tracer("Speak: begin", "Speak: end");
- LogInfo("Speak: entered");
- try
+ // Only create scope tracer for trace level to avoid overhead
+ if (logger.should_log(spdlog::level::trace))
{
- LogInfo("Speak: state synth={} rest={} sherpa={} cancelFuture={}",
- m_synthesizer ? 1 : 0,
- m_restApi ? 1 : 0,
- m_sherpaOnnx ? 1 : 0,
- m_lastCancellingFuture.valid() ? 1 : 0);
- LogErr("SpeakDiag: stage=after-state-log");
+ static ScopeTracer tracer("Speak: begin", "Speak: end");
+ }
+ try
+ {
// Check args (avoid legacy SP_IS_BAD_* probes which can fault in modern processes).
if (!pOutputSite || !pTextFragList)
{
- LogWarn("Speak: bad input pointers");
+ if (logger.should_log(spdlog::level::warn))
+ LogWarn("Speak: bad input pointers");
return E_INVALIDARG;
}
- LogInfo("Speak: pointer validation passed");
- LogErr("SpeakDiag: stage=after-pointer-check");
- if (!m_synthesizer && !m_restApi && !m_sherpaOnnx)
+
+ if (!m_rustTts)
{
- LogWarn("Speak: no engine initialized");
+ if (logger.should_log(spdlog::level::err))
+ LogErr("Speak: no RustTts engine initialized");
return SPERR_UNINITIALIZED;
}
- LogInfo("Speak: engine presence check passed");
- LogErr("SpeakDiag: stage=after-engine-check");
if (m_lastCancellingFuture.valid())
{
- LogInfo("Speak: waiting previous cancellation");
// The previous cancellation is still in progress. Wait for it.
while (m_lastCancellingFuture.wait_for(std::chrono::milliseconds(0)) == std::future_status::timeout)
{
@@ -191,183 +211,91 @@ STDMETHODIMP CTTSEngine::Speak(DWORD /*dwSpeakFlags*/,
// We can return immediately, since nothing has been done yet.
return S_OK;
}
- Sleep(0); // Reduce cancellation latency
+ Sleep(1); // Use Sleep(1) instead of Sleep(0) to avoid CPU spinning
}
// Cancellation completed. Clear the future.
m_lastCancellingFuture = {};
- LogInfo("Speak: previous cancellation completed");
}
// Clear m_pOutputSite automatically when Speak is completed
ScopeGuard siteDeleter([this]()
{
- std::lock_guard lock(m_outputSiteMutex);
- m_pOutputSite = nullptr;
+ std::lock_guard lock(m_outputSiteMutex);
+ if (m_pOutputSite)
+ {
+ m_pOutputSite->Release();
+ m_pOutputSite = nullptr;
+ }
});
- LogInfo("Speak: scope guard created");
- LogErr("SpeakDiag: stage=after-scopeguard");
- m_pOutputSite = pOutputSite;
- LogInfo("Speak: output site assigned");
- LogErr("SpeakDiag: stage=after-outputsite-assign");
-
- LogInfo("Speak: pre-branch sherpa={}", m_sherpaOnnx ? 1 : 0);
- LogErr("SpeakDiag: stage=before-sherpa-branch");
- if (m_sherpaOnnx)
- {
- LogInfo("Speak: Sherpa path selected");
- LogErr("SpeakDiag: stage=in-sherpa-branch");
- std::wstring plainTextW = ExtractSherpaPlainText(pTextFragList);
- LogInfo("Speak: Sherpa extracted text length={}", plainTextW.size());
- if (plainTextW.empty())
- {
- LogDebug("Speak: Sherpa plain text is empty");
- FinishSimulatingBookmarkEvents(m_compensatedSilentBytes);
- return S_OK;
- }
-
- m_compensatedSilenceWritten = false;
- m_compensatedSilentBytes = 0;
- m_lastSilentBytes = 0;
- m_thisSpeakStartedTicks = _GetTickCount();
-
- // Keep Sherpa synthesis on the caller thread to avoid cross-thread COM access
- // to ISpTTSEngineSite when writing audio.
- m_sherpaAbortRequested.store(false, std::memory_order_relaxed);
- LogInfo("Speak: Sherpa generation begin");
- GenerateSherpaOnnxAudio(WStringToUTF8(plainTextW));
- LogInfo("Speak: Sherpa generation end");
- m_lastSpeakCompletedTicks = _GetTickCount();
- return S_OK;
- }
- if (m_genericTts)
+ // Store the output site with proper reference counting
{
- LogInfo("Speak: Generic HTTP TTS path selected");
- std::wstring plainTextW = ExtractSherpaPlainText(pTextFragList);
- if (plainTextW.empty())
+ std::lock_guard lock(m_outputSiteMutex);
+ if (pOutputSite)
{
- FinishSimulatingBookmarkEvents(m_compensatedSilentBytes);
- return S_OK;
+ pOutputSite->AddRef();
+ m_pOutputSite = pOutputSite;
}
-
- m_compensatedSilenceWritten = false;
- m_compensatedSilentBytes = 0;
- m_lastSilentBytes = 0;
- m_thisSpeakStartedTicks = _GetTickCount();
-
- m_sherpaAbortRequested.store(false, std::memory_order_relaxed);
- LogInfo("Speak: HTTP TTS generation begin");
- try {
- m_genericTts->Speak(WStringToUTF8(plainTextW),
- [this](const uint8_t* data, uint32_t len) {
- return OnAudioData(const_cast(data), len);
- });
- } catch (const std::exception& ex) {
- LogErr("HTTP TTS synthesis failed: {}", ex.what());
- }
- LogInfo("Speak: HTTP TTS generation end");
- m_lastSpeakCompletedTicks = _GetTickCount();
- return S_OK;
- }
-
- ULONGLONG eventInterests = 0;
- pOutputSite->GetEventInterest(&eventInterests);
- if (m_synthesizer)
- SetupSynthesizerEvents(eventInterests);
- else if (m_restApi)
- SetupRestAPIEvents(eventInterests);
- else
- ClearSynthesizerEvents();
-
- if (!BuildSSML(pTextFragList))
- {
- LogDebug("Speak: Built SSML with no speech: {}", m_ssml);
- // Simulate the bookmark events ourselves without doing actual speech synthesis
- FinishSimulatingBookmarkEvents(m_compensatedSilentBytes);
- return S_OK;
}
- LogDebug("Speak: Built SSML: {}", m_ssml);
-
m_compensatedSilenceWritten = false;
m_compensatedSilentBytes = 0;
m_lastSilentBytes = 0;
m_thisSpeakStartedTicks = _GetTickCount();
- m_onlineDelayOptimization =
- !m_onlineVoiceName.empty() && RegOpenConfigKey().GetDword(L"EnableOnlineDelayOptimization");
-
- std::future future;
m_sherpaAbortRequested.store(false, std::memory_order_relaxed);
- if (m_synthesizer)
- {
- future = std::async(std::launch::async, [this]() { CheckSynthesisResult(m_synthesizer->SpeakSsml(m_ssml)); });
- }
- else
- {
- future = m_restApi->SpeakAsync(m_ssml);
- }
-
- while (!(pOutputSite->GetActions() & SPVES_ABORT)
- && future.wait_for(std::chrono::milliseconds(0)) == std::future_status::timeout)
- {
- if (pOutputSite->GetActions() & SPVES_SKIP)
- {
- // Skipping is not supported
- LogWarn("Speak: Skipping not supported, ignored");
- pOutputSite->CompleteSkip(0);
- }
- Sleep(10);
- }
+ // Clear boundary queue for this utterance
+ m_pendingBoundaries.clear();
+ m_boundaryIndex = 0;
+ m_totalAudioBytesWritten = 0;
- if (pOutputSite->GetActions() & SPVES_ABORT) // requested stop
+ std::string speakText;
+ if (m_rustTtsUseSsml)
{
- LogDebug("Speak: Requested stop");
- if (m_sherpaOnnx)
- {
- m_sherpaAbortRequested.store(true, std::memory_order_relaxed);
- future.wait();
- }
- else if (m_synthesizer)
+ if (!BuildSSML(pTextFragList))
{
- // Cancellation might not finish, but we won't wait for it.
- // Return immediately on requested stop.
- // Create a new async future to wait for cancellation,
- // and check it the next time Speak is called.
- m_lastCancellingFuture = std::async(
- std::launch::async, [this, future = std::move(future)]()
- {
- // Wait for SynthesisStarted event first.
- // Cancellation does nothing if SynthesisStarted hasn't been fired.
- while (!m_synthesizerStarted.load(std::memory_order_relaxed))
- Sleep(0);
- m_synthesizer->StopSpeakingAsync().wait();
- future.wait();
- });
+ if (logger.should_log(spdlog::level::debug))
+ LogDebug("Speak: RustTts SSML built with no speech content");
+ FinishSimulatingBookmarkEvents(m_compensatedSilentBytes);
+ return S_OK;
}
- else
- m_restApi->Stop();
-
- m_lastSpeakCompletedTicks = 0;
+ speakText = WStringToUTF8(m_ssml);
}
else
{
- future.get(); // wait for the future and get its stored exception thrown
- if (m_isEdgeVoice)
+ std::wstring plainTextW = ExtractSherpaPlainTextWithMap(pTextFragList);
+ if (plainTextW.empty())
{
- // finish all remaining bookmark events at the end
- FinishSimulatingBookmarkEvents(
- m_restApi->GetWaveBytesWritten() + m_compensatedSilentBytes - m_lastSilentBytes);
+ FinishSimulatingBookmarkEvents(m_compensatedSilentBytes);
+ return S_OK;
}
+ speakText = WStringToUTF8(plainTextW);
+ }
- m_lastSpeakCompletedTicks = _GetTickCount();
+ // Synchronous synthesis — prevents race condition where boundary events
+ // from a previous Speak() are processed against new text by System.Speech.
+ // Abort checking via SPVES_ABORT is not possible during synthesis.
+ if (logger.should_log(spdlog::level::info))
+ LogInfo("Speak: RustTts generation begin");
+ try {
+ if (m_rustTtsUseSsml)
+ m_rustTts->SpeakSsml(speakText);
+ else
+ m_rustTts->Speak(speakText);
+ } catch (const std::exception& ex) {
+ LogErr("RustTts synthesis failed: {}", ex.what());
}
+ if (logger.should_log(spdlog::level::info))
+ LogInfo("Speak: RustTts generation end");
+ m_lastSpeakCompletedTicks = _GetTickCount();
+
return S_OK;
}
catch (const std::bad_alloc&)
{
- LogCritical("Out of memory");
+ if (logger.should_log(spdlog::level::critical))
+ LogCritical("Out of memory");
return E_OUTOFMEMORY;
}
catch (const std::system_error& ex)
@@ -380,7 +308,8 @@ STDMETHODIMP CTTSEngine::Speak(DWORD /*dwSpeakFlags*/,
}
catch (...) // C++ exceptions should not cross COM boundary
{
- LogErr("Speak: Unknown error");
+ if (logger.should_log(spdlog::level::err))
+ LogErr("Speak: Unknown error");
return E_FAIL;
}
} /* CTTSEngine::Speak */
@@ -388,58 +317,51 @@ STDMETHODIMP CTTSEngine::Speak(DWORD /*dwSpeakFlags*/,
STDMETHODIMP CTTSEngine::GetOutputFormat(const GUID* /*pTargetFormatId*/, const WAVEFORMATEX* /*pTargetWaveFormatEx*/,
GUID* pDesiredFormatId, WAVEFORMATEX** ppCoMemDesiredWaveFormatEx) noexcept
{
- // For Sherpa voices, prefer model sample rate to avoid speed/pitch distortion.
- if (m_isSherpaOnnxVoice)
+ // For offline voices, prefer model sample rate from registry metadata.
{
DWORD sampleRate = 0;
- // First choice: active Sherpa engine output format (ground truth).
- if (m_sherpaOnnx)
- {
- const int sr = m_sherpaOnnx->GetSampleRate();
- if (sr > 0)
- sampleRate = static_cast(sr);
- }
-
- // Fallback: token metadata from model catalog.
- if (sampleRate == 0 && m_cpToken)
+ // Token metadata from model catalog.
+ if (m_cpToken)
{
CComPtr pConfigKey;
if (SUCCEEDED(m_cpToken->OpenKey(L"VoiceGardenConfig", &pConfigKey)) && pConfigKey)
(void)pConfigKey->GetDWORD(L"SampleRate", &sampleRate);
}
- auto pickFormat = [](DWORD sr) -> SPSTREAMFORMAT {
- switch (sr)
- {
- case 8000: return SPSF_8kHz16BitMono;
- case 11025: return SPSF_11kHz16BitMono;
- case 12000: return SPSF_12kHz16BitMono;
- case 16000: return SPSF_16kHz16BitMono;
- case 22050: return SPSF_22kHz16BitMono;
- case 24000: return SPSF_24kHz16BitMono;
- case 32000: return SPSF_32kHz16BitMono;
- case 44100: return SPSF_44kHz16BitMono;
- case 48000: return SPSF_48kHz16BitMono;
- default:
- // Nearest commonly supported mono 16-bit PCM format.
- if (sr <= 9512) return SPSF_8kHz16BitMono;
- if (sr <= 11512) return SPSF_11kHz16BitMono;
- if (sr <= 14000) return SPSF_12kHz16BitMono;
- if (sr <= 19025) return SPSF_16kHz16BitMono;
- if (sr <= 23025) return SPSF_22kHz16BitMono;
- if (sr <= 28000) return SPSF_24kHz16BitMono;
- if (sr <= 38050) return SPSF_32kHz16BitMono;
- if (sr <= 46050) return SPSF_44kHz16BitMono;
- return SPSF_48kHz16BitMono;
- }
- };
+ if (sampleRate > 0)
+ {
+ auto pickFormat = [](DWORD sr) -> SPSTREAMFORMAT {
+ switch (sr)
+ {
+ case 8000: return SPSF_8kHz16BitMono;
+ case 11025: return SPSF_11kHz16BitMono;
+ case 12000: return SPSF_12kHz16BitMono;
+ case 16000: return SPSF_16kHz16BitMono;
+ case 22050: return SPSF_22kHz16BitMono;
+ case 24000: return SPSF_24kHz16BitMono;
+ case 32000: return SPSF_32kHz16BitMono;
+ case 44100: return SPSF_44kHz16BitMono;
+ case 48000: return SPSF_48kHz16BitMono;
+ default:
+ if (sr <= 9512) return SPSF_8kHz16BitMono;
+ if (sr <= 11512) return SPSF_11kHz16BitMono;
+ if (sr <= 14000) return SPSF_12kHz16BitMono;
+ if (sr <= 19025) return SPSF_16kHz16BitMono;
+ if (sr <= 23025) return SPSF_22kHz16BitMono;
+ if (sr <= 28000) return SPSF_24kHz16BitMono;
+ if (sr <= 38050) return SPSF_32kHz16BitMono;
+ if (sr <= 46050) return SPSF_44kHz16BitMono;
+ return SPSF_48kHz16BitMono;
+ }
+ };
- const SPSTREAMFORMAT fmt = pickFormat(sampleRate == 0 ? 24000 : sampleRate);
- return SpConvertStreamFormatEnum(fmt, pDesiredFormatId, ppCoMemDesiredWaveFormatEx);
+ const SPSTREAMFORMAT fmt = pickFormat(sampleRate);
+ return SpConvertStreamFormatEnum(fmt, pDesiredFormatId, ppCoMemDesiredWaveFormatEx);
+ }
}
- // Embedded/cloud default
+ // Default
return SpConvertStreamFormatEnum(SPSF_24kHz16BitMono, pDesiredFormatId, ppCoMemDesiredWaveFormatEx);
}
@@ -471,8 +393,7 @@ void CTTSEngine::InitPhoneConverter()
void CTTSEngine::InitVoice()
{
CComPtr pConfigKey;
- CSpDynamicString pszRegion, pszKey, pszPath, pszVoice;
-
+
LogInfo("TTS init: opening VoiceGardenConfig key");
HRESULT hr = m_cpToken->OpenKey(L"VoiceGardenConfig", &pConfigKey); // this key must exist
LogInfo("TTS init: OpenKey VoiceGardenConfig returned hr={:#x}", static_cast(hr));
@@ -484,26 +405,10 @@ void CTTSEngine::InitVoice()
if (FAILED(hr)) dwErrorMode = 0;
m_errorMode = (ErrorMode)std::clamp(dwErrorMode, 0UL, 2UL);
- RegKey key = RegOpenConfigKey();
-
- // Try SherpaOnnx first (offline local voices)
- if (InitSherpaOnnxVoice(pConfigKey))
+ // All voices route through rust-tts-wrapper (tts_wrapper.dll).
+ if (InitRustTtsVoice(pConfigKey))
return;
- if (IsWindows7OrGreater() // Azure Speech SDK requires at least Win 7
- || key.GetDword(L"ForceEnableAzureSpeechSDK"))
- {
- if (InitLocalVoice(pConfigKey))
- return;
- if (key.GetDword(L"UseAzureSpeechSDKForAzureVoices")
- && InitCloudVoiceSynthesizer(pConfigKey))
- return;
- }
- if (InitCloudVoiceRestAPI(pConfigKey))
- return;
- if (InitGenericHttpVoice(pConfigKey))
- return;
-
throw std::invalid_argument("Invalid VoiceGardenConfig configuration.");
}
@@ -518,430 +423,211 @@ inline static bool CheckHrNotFound(HRESULT hr)
LSTATUS TryLoadAzureSpeechSDK();
-bool CTTSEngine::InitLocalVoice(ISpDataKey* pConfigKey)
+bool CTTSEngine::InitRustTtsVoice(ISpDataKey* pConfigKey)
{
- if (TryLoadAzureSpeechSDK() != ERROR_SUCCESS)
- return false; // fallback
+ // Try to use rust-tts-wrapper for cloud engines.
+ // Falls through (returns false) if tts_wrapper.dll isn't loaded,
+ // so the existing GenericHttpTts / SpeechRestAPI paths are used instead.
- CSpDynamicString pszPath, pszKey;
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"Path", &pszPath)))
+ auto& loader = RustTts::Loader::Instance();
+ if (!loader.Initialize() || !loader.IsLoaded())
+ {
+ LogErr("TTS init: tts_wrapper.dll not loaded — cannot initialize voice");
return false;
+ }
- // Newer Speech SDK requires a license instead of a key.
- // If the voice is using a key, insert "Key:" before the key.
- if (!CheckHrNotFound(pConfigKey->GetStringValue(L"Key", &pszKey)))
- MergeIntoCoString(pszKey, L"Key:", pszKey.m_psz);
- else if (CheckHrNotFound(pConfigKey->GetStringValue(L"License", &pszKey)))
- return false;
+ // Determine engine type from registry config.
+ // Cloud voices have EngineType (Azure, Edge, OpenAI, Google, etc.)
+ // SherpaOnnx voices have SherpaOnnxModelPath (no EngineType).
+ CSpDynamicString pszEngineType;
+ CSpDynamicString pszSherpaModelPath;
+ bool hasEngineType = !CheckHrNotFound(pConfigKey->GetStringValue(L"EngineType", &pszEngineType));
+ bool hasSherpaPath = !CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxModelPath", &pszSherpaModelPath));
- auto path = WStringToUTF8(pszPath.m_psz);
+ std::string engineType;
+ std::string lowerType;
- if (logger.should_log(spdlog::level::warn))
+ if (hasEngineType)
{
- if (!std::all_of(path.begin(), path.end(),
- [](unsigned char ch) { return ch < 128; }))
- {
- LogWarn("TTS init: Local voice path contains non-ASCII characters, may not work correctly: {}",
- path);
- }
+ engineType = pszEngineType.m_psz ? WStringToUTF8(std::wstring(pszEngineType.m_psz)) : "";
+ lowerType = engineType;
+ std::transform(lowerType.begin(), lowerType.end(), lowerType.begin(), ::tolower);
+ // Sherpa EngineType maps to "sherpaonnx" in rust-tts-wrapper
+ if (lowerType == "sherpa")
+ lowerType = "sherpaonnx";
}
-
- auto config = EmbeddedSpeechConfig::FromPath(path);
-
- config->SetSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat::Riff24Khz16BitMonoPcm);
- config->SetProperty(PropertyId::SpeechServiceResponse_RequestSentenceBoundary, "true");
- config->SetProperty(PropertyId::SpeechServiceResponse_RequestPunctuationBoundary, "false");
-
- // get the voice name by loading it first
- auto synthesizer = SpeechSynthesizer::FromConfig(config);
- auto result = synthesizer->GetVoicesAsync().get();
- if (result->Reason != ResultReason::VoicesListRetrieved
- || result->Voices.empty())
+ else if (hasSherpaPath)
{
- LogErr("Invalid local voice folder: {}", path);
- throw std::invalid_argument(UTF8ToAnsi(result->ErrorDetails));
+ // SherpaOnnx voice detected by SherpaOnnxModelPath (no EngineType field)
+ engineType = "Sherpa";
+ lowerType = "sherpaonnx"; // Rust engine ID is "sherpaonnx"
}
- auto& voiceName = result->Voices[0]->Name;
- config->SetSpeechSynthesisVoice(voiceName, WStringToUTF8(pszKey.m_psz));
-
- if (m_errorMode == ErrorMode::ProbeForError)
+ else
{
- auto synthesizer = SpeechSynthesizer::FromConfig(config, nullptr);
- CheckSynthesisResult(synthesizer->SpeakText("")); // test for possible error
+ return false;
}
- m_synthesizer = SpeechSynthesizer::FromConfig(config, AudioConfig::FromStreamOutput(
- AudioOutputStream::CreatePushStream(std::bind_front(&CTTSEngine::OnAudioData, this))));
+ // All engine types are handled by rust-tts-wrapper.
+ // Read credentials for the engine.
- LogInfo("Local voice created: {}", voiceName);
- return true;
-}
+ // Read credentials from the token config
+ CSpDynamicString pszVoice, pszKey, pszRegion;
+ CheckHrNotFound(pConfigKey->GetStringValue(L"Voice", &pszVoice));
+ CheckHrNotFound(pConfigKey->GetStringValue(L"Key", &pszKey));
+ CheckHrNotFound(pConfigKey->GetStringValue(L"Region", &pszRegion));
-bool CTTSEngine::InitSherpaOnnxVoice(ISpDataKey* pConfigKey)
-{
- LogInfo("Sherpa init: probing token for Sherpa config");
- // Check if this is a SherpaOnnx voice configuration by checking for model type
- CSpDynamicString pszModelType;
- int modelTypeValue = 0; // Default to Vits (0)
- if (!CheckHrNotFound(pConfigKey->GetDWORD(L"SherpaOnnxModelType", (DWORD*)&modelTypeValue))) {
- // Model type is specified
- } else {
- // For backward compatibility, check if SherpaOnnxModelPath exists (old style)
- CSpDynamicString pszDummy;
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxModelPath", &pszDummy))) {
- return false; // Not a SherpaOnnx voice
- }
- // Old-style config is always VITS
- modelTypeValue = 0;
+ // Build credentials JSON for rust-tts-wrapper
+ std::string credsJson;
+ if (lowerType == "google" || lowerType == "openai" || lowerType == "elevenlabs" ||
+ lowerType == "cartesia" || lowerType == "deepgram" || lowerType == "fishaudio" ||
+ lowerType == "hume" || lowerType == "mistral" || lowerType == "murf" ||
+ lowerType == "resemble" || lowerType == "unrealspeech" || lowerType == "upliftai" ||
+ lowerType == "xai" || lowerType == "modelslab")
+ {
+ std::string key = pszKey.m_psz ? WStringToUTF8(std::wstring(pszKey.m_psz)) : "";
+ credsJson = "{\"apiKey\":\"" + key + "\"}";
}
-
- // Token metadata uses SherpaOnnx::ModelType (Vits/Matcha/Kokoro/Piper/MMS),
- // while runtime engine config uses SherpaOnnx::TtsModelType (Vits/Matcha/Kokoro/Unknown).
- // Normalize so Piper/MMS are treated as Vits-family models.
- SherpaOnnx::TtsModelType modelType = SherpaOnnx::TtsModelType::Vits;
- switch (modelTypeValue)
+ else if (lowerType == "azure")
{
- case 1:
- modelType = SherpaOnnx::TtsModelType::Matcha;
- break;
- case 2:
- modelType = SherpaOnnx::TtsModelType::Kokoro;
- break;
- default:
- modelType = SherpaOnnx::TtsModelType::Vits;
- break;
+ std::string key = pszKey.m_psz ? WStringToUTF8(std::wstring(pszKey.m_psz)) : "";
+ std::string region = pszRegion.m_psz ? WStringToUTF8(std::wstring(pszRegion.m_psz)) : "";
+ credsJson = "{\"subscriptionKey\":\"" + key + "\",\"region\":\"" + region + "\"}";
}
- LogInfo("Sherpa init: model type value = {}", modelTypeValue);
-
- try
+ else if (lowerType == "watson")
{
- SherpaOnnx::ModelConfig config;
- config.modelType = modelType;
- // Baseline parity with vanilla Sherpa sample first; optimize later.
- config.numThreads = 1;
- config.debug = false;
- config.provider = "cpu";
- config.maxNumSentences = 1;
-
- CSpDynamicString pszVoiceName;
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxVoiceName", &pszVoiceName))) {
- pszVoiceName = L"SherpaOnnx Voice";
- }
- config.voiceName = WStringToUTF8(std::wstring(pszVoiceName.m_psz));
-
- switch (modelType) {
- case SherpaOnnx::TtsModelType::Matcha: {
- // Matcha: acoustic_model + vocoder + tokens
- CSpDynamicString pszAcousticModel, pszVocoder, pszTokens, pDataDir, pLexicon, pDictDir;
-
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxAcousticModel", &pszAcousticModel))) {
- LogWarn("SherpaOnnx Matcha voice missing AcousticModel");
- return false;
- }
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxVocoder", &pszVocoder))) {
- LogWarn("SherpaOnnx Matcha voice missing Vocoder");
- return false;
- }
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxTokens", &pszTokens))) {
- LogWarn("SherpaOnnx Matcha voice missing Tokens");
- return false;
- }
-
- config.matcha.acousticModel = WStringToUTF8(std::wstring(pszAcousticModel.m_psz));
- config.matcha.vocoder = WStringToUTF8(std::wstring(pszVocoder.m_psz));
- config.matcha.tokens = WStringToUTF8(std::wstring(pszTokens.m_psz));
-
- // Optional parameters
- CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxDataDir", &pDataDir));
- CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxLexicon", &pLexicon));
- CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxDictDir", &pDictDir));
-
- if (pDataDir.m_psz && *pDataDir.m_psz)
- config.matcha.dataDir = WStringToUTF8(std::wstring(pDataDir.m_psz));
- if (pLexicon.m_psz && *pLexicon.m_psz)
- config.matcha.lexicon = WStringToUTF8(std::wstring(pLexicon.m_psz));
- if (pDictDir.m_psz && *pDictDir.m_psz)
- config.matcha.dictDir = WStringToUTF8(std::wstring(pDictDir.m_psz));
-
- config.matcha.noiseScale = 1.0f;
- config.matcha.lengthScale = 1.0f;
- break;
- }
-
- case SherpaOnnx::TtsModelType::Kokoro: {
- // Kokoro: model + voices + tokens
- CSpDynamicString pszModel, pszVoices, pszTokens, pLexicon, pDataDir, pDictDir, pLang;
-
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxModelPath", &pszModel))) {
- LogWarn("SherpaOnnx Kokoro voice missing ModelPath");
- return false;
- }
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxVoices", &pszVoices))) {
- LogWarn("SherpaOnnx Kokoro voice missing Voices");
- return false;
- }
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxTokens", &pszTokens))) {
- LogWarn("SherpaOnnx Kokoro voice missing Tokens");
- return false;
- }
-
- config.kokoro.model = WStringToUTF8(std::wstring(pszModel.m_psz));
- config.kokoro.voices = WStringToUTF8(std::wstring(pszVoices.m_psz));
- config.kokoro.tokens = WStringToUTF8(std::wstring(pszTokens.m_psz));
-
- // Optional parameters
- CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxDataDir", &pDataDir));
- CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxLexicon", &pLexicon));
- CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxDictDir", &pDictDir));
- CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxLang", &pLang));
-
- if (pDataDir.m_psz && *pDataDir.m_psz)
- config.kokoro.dataDir = WStringToUTF8(std::wstring(pDataDir.m_psz));
- if (pLexicon.m_psz && *pLexicon.m_psz)
- config.kokoro.lexicon = WStringToUTF8(std::wstring(pLexicon.m_psz));
- if (pDictDir.m_psz && *pDictDir.m_psz)
- config.kokoro.dictDir = WStringToUTF8(std::wstring(pDictDir.m_psz));
- if (pLang.m_psz && *pLang.m_psz)
- config.kokoro.lang = WStringToUTF8(std::wstring(pLang.m_psz));
- else
- {
- CComPtr pAttrKey;
- CSpDynamicString locale;
- if (SUCCEEDED(m_cpToken->OpenKey(SPTOKENKEY_ATTRIBUTES, &pAttrKey)) &&
- SUCCEEDED(pAttrKey->GetStringValue(L"Locale", &locale)) &&
- locale.m_psz && *locale.m_psz)
- {
- std::wstring loc = locale.m_psz;
- size_t delim = loc.find_first_of(L",; ");
- if (delim != std::wstring::npos)
- loc = loc.substr(0, delim);
- std::replace(loc.begin(), loc.end(), L'_', L'-');
- std::transform(loc.begin(), loc.end(), loc.begin(), ::towlower);
- if (!loc.empty())
- config.kokoro.lang = WStringToUTF8(loc);
- }
- if (config.kokoro.lang.empty())
- config.kokoro.lang = "en-us";
- }
-
- config.kokoro.lengthScale = 1.0f;
- break;
- }
-
- default: {
- // VITS/Piper/MMS: model + tokens
- CSpDynamicString pszModel, pszTokens, pDataDir, pLexicon, pDictDir;
-
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxModelPath", &pszModel))) {
- LogWarn("SherpaOnnx VITS voice missing ModelPath");
- return false;
- }
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxTokens", &pszTokens))) {
- LogWarn("SherpaOnnx VITS voice missing Tokens");
- return false;
- }
-
- config.vits.model = WStringToUTF8(std::wstring(pszModel.m_psz));
- config.vits.tokens = WStringToUTF8(std::wstring(pszTokens.m_psz));
-
- // Optional parameters: keep only data_dir in baseline path.
- CheckHrNotFound(pConfigKey->GetStringValue(L"SherpaOnnxDataDir", &pDataDir));
-
- if (pDataDir.m_psz && *pDataDir.m_psz)
- config.vits.dataDir = WStringToUTF8(std::wstring(pDataDir.m_psz));
- LogInfo("Sherpa init: vits model='{}'", config.vits.model);
- LogInfo("Sherpa init: vits tokens='{}'", config.vits.tokens);
- LogInfo("Sherpa init: vits data_dir='{}'", config.vits.dataDir);
- try
- {
- std::error_code ecModel, ecTokens, ecData;
- bool hasModel = std::filesystem::is_regular_file(std::filesystem::u8path(config.vits.model), ecModel);
- bool hasTokens = std::filesystem::is_regular_file(std::filesystem::u8path(config.vits.tokens), ecTokens);
- bool hasDataDir = config.vits.dataDir.empty() ||
- std::filesystem::is_directory(std::filesystem::u8path(config.vits.dataDir), ecData);
- LogInfo("Sherpa init: path checks model={} tokens={} data_dir={}", hasModel ? 1 : 0, hasTokens ? 1 : 0, hasDataDir ? 1 : 0);
- if (!hasDataDir)
- {
- LogWarn("Sherpa init: data_dir path missing, clearing optional data_dir");
- config.vits.dataDir.clear();
- }
- }
- catch (...)
- {
- LogWarn("Sherpa init: failed to evaluate path checks");
- }
- break;
- }
- }
-
- std::string engineKey = BuildSherpaEngineKey(config);
+ std::string key = pszKey.m_psz ? WStringToUTF8(std::wstring(pszKey.m_psz)) : "";
+ std::string region = pszRegion.m_psz ? WStringToUTF8(std::wstring(pszRegion.m_psz)) : "";
+ credsJson = "{\"apiKey\":\"" + key + "\",\"region\":\"" + region + "\"}";
+ }
+ else if (lowerType == "playht")
+ {
+ std::string key = pszKey.m_psz ? WStringToUTF8(std::wstring(pszKey.m_psz)) : "";
+ std::string userId = pszRegion.m_psz ? WStringToUTF8(std::wstring(pszRegion.m_psz)) : "";
+ credsJson = "{\"apiKey\":\"" + key + "\",\"userId\":\"" + userId + "\"}";
+ }
+ else if (lowerType == "witai")
+ {
+ std::string key = pszKey.m_psz ? WStringToUTF8(std::wstring(pszKey.m_psz)) : "";
+ credsJson = "{\"token\":\"" + key + "\"}";
+ }
+ else if (lowerType == "edge")
+ {
+ // Edge is credential-free
+ credsJson = "{}";
+ }
+ else if (lowerType == "sherpaonnx")
+ {
+ // SherpaOnnx via Rust. Derive modelId and modelPath from the registry.
+ // The Rust wrapper expects: modelPath=,
+ // modelId=.
+ //
+ // Path structures:
+ // MMS: models/mms_eng/model.onnx (flat — modelId = mms_eng)
+ // Kokoro: models/kokoro-en-en-19/sub/model.onnx (nested — modelId = kokoro-en-en-19)
+ // Piper: models/piper-en-amy-low/sub/file.onnx (nested — modelId = piper-en-amy-low)
+ //
+ // Solution: walk up from the .onnx file to find the "models" directory,
+ // then the first directory after it is the modelId.
+ if (hasSherpaPath && pszSherpaModelPath.m_psz)
{
- std::lock_guard guard(g_sherpaInitMutex);
- if (g_cachedSherpaEngine && g_cachedSherpaEngine->IsValid() && g_cachedSherpaKey == engineKey)
+ std::filesystem::path onnxPath(pszSherpaModelPath.m_psz);
+ auto p = onnxPath.parent_path();
+ while (p.has_parent_path() && p.filename() != L"models")
+ p = p.parent_path();
+
+ if (p.filename() == L"models")
{
- m_sherpaOnnx = g_cachedSherpaEngine;
- LogInfo("Sherpa init: reusing cached engine instance");
+ auto rel = std::filesystem::relative(onnxPath.parent_path(), p);
+ std::string modelId = rel.begin()->string();
+ std::string basePath = p.string();
+ std::replace(basePath.begin(), basePath.end(), '\\', '/');
+ credsJson = "{\"modelId\":\"" + modelId + "\",\"modelPath\":\"" + basePath + "\"}";
+ LogInfo("RustTts: SherpaOnnx credentials: {}", credsJson);
}
else
{
- LogInfo("Sherpa init: creating Sherpa engine instance");
- auto created = std::make_shared(config);
- LogInfo("Sherpa init: Sherpa engine instance created");
- if (!created->IsValid())
- {
- LogErr("Failed to initialize SherpaOnnx engine for voice: {}. reason={}",
- WStringToUTF8(std::wstring(pszVoiceName.m_psz)), created->GetLastError());
- m_sherpaOnnx.reset();
- return false;
- }
- g_cachedSherpaEngine = created;
- g_cachedSherpaKey = engineKey;
- m_sherpaOnnx = std::move(created);
+ LogWarn("RustTts: Could not find 'models' directory in path: {}", onnxPath.string());
+ return false;
}
}
-
- if (!m_sherpaOnnx->IsValid())
+ else
{
- LogErr("Failed to initialize SherpaOnnx engine for voice: {}. reason={}",
- WStringToUTF8(std::wstring(pszVoiceName.m_psz)), m_sherpaOnnx->GetLastError());
- m_sherpaOnnx.reset();
+ LogWarn("RustTts: SherpaOnnx voice has no SherpaOnnxModelPath");
return false;
}
-
- m_isSherpaOnnxVoice = true;
- m_isEdgeVoice = false; // Not an Edge voice
-
- int sampleRate = m_sherpaOnnx->GetSampleRate();
- LogInfo("SherpaOnnx voice created: {} (model type: {}, sample rate: {}Hz)",
- WStringToUTF8(std::wstring(pszVoiceName.m_psz)),
- static_cast(modelType),
- sampleRate);
- return true;
}
- catch (const std::exception& ex)
+ else
{
- LogErr("SherpaOnnx initialization failed: {}", ex.what());
- m_sherpaOnnx.reset();
- return false;
+ // Generic: pass apiKey
+ std::string key = pszKey.m_psz ? WStringToUTF8(std::wstring(pszKey.m_psz)) : "";
+ credsJson = "{\"apiKey\":\"" + key + "\"}";
}
-}
-bool CTTSEngine::InitCloudVoiceSynthesizer(ISpDataKey* pConfigKey)
-{
- if (LSTATUS stat = TryLoadAzureSpeechSDK(); stat != ERROR_SUCCESS)
+ // Create the RustTts engine
+ m_rustTts = std::make_unique();
+ if (!m_rustTts->Create(lowerType, credsJson))
{
- LogInfo("TTS init: Azure Speech SDK failed ({}), falling back to Rest API",
- std::system_category().message(stat));
- return false; // fallback
- }
-
- CSpDynamicString pszKey, pszRegion, pszVoice;
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"Key", &pszKey))
- || CheckHrNotFound(pConfigKey->GetStringValue(L"Region", &pszRegion))
- || CheckHrNotFound(pConfigKey->GetStringValue(L"Voice", &pszVoice)))
+ LogWarn("RustTts: failed to create engine '{}', falling back", lowerType);
+ m_rustTts.reset();
return false;
-
- auto config = SpeechConfig::FromSubscription(WStringToUTF8(pszKey.m_psz), WStringToUTF8(pszRegion.m_psz));
-
- config->SetSpeechSynthesisOutputFormat(SpeechSynthesisOutputFormat::Riff24Khz16BitMonoPcm);
- config->SetProperty(PropertyId::SpeechServiceResponse_RequestSentenceBoundary, "true");
- config->SetProperty(PropertyId::SpeechServiceResponse_RequestPunctuationBoundary, "false");
-
- auto proxy = GetProxyForUrl("https://" + WStringToUTF8(pszRegion.m_psz) + AZURE_TTS_HOST_AFTER_REGION);
- if (!proxy.empty())
- {
- auto url = ParseUrl(proxy);
- uint32_t port = 80;
- std::from_chars(url.port.data(), url.port.data() + url.port.size(), port);
- config->SetProxy(std::string(url.host), port);
}
- config->SetSpeechSynthesisVoiceName(WStringToUTF8(pszVoice.m_psz));
- m_onlineVoiceName = pszVoice;
-
- if (m_errorMode == ErrorMode::ProbeForError)
+ // Set voice if specified
+ if (pszVoice.m_psz && *pszVoice.m_psz)
{
- auto synthesizer = SpeechSynthesizer::FromConfig(config, nullptr);
- CheckSynthesisResult(synthesizer->SpeakText("")); // test for possible error
+ m_rustTts->SetVoice(WStringToUTF8(std::wstring(pszVoice.m_psz)));
}
- m_synthesizer = SpeechSynthesizer::FromConfig(config, AudioConfig::FromStreamOutput(
- AudioOutputStream::CreatePushStream(std::bind_front(&CTTSEngine::OnAudioData, this))));
-
- LogInfo("Cloud voice (Azure Speech SDK) created: {}", pszVoice.m_psz);
- return true;
-}
-
-bool CTTSEngine::InitCloudVoiceRestAPI(ISpDataKey* pConfigKey)
-{
- m_restApi = std::make_unique();
-
- CSpDynamicString pszVoice, pszKey;
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"Voice", &pszVoice)))
- return false;
- m_onlineVoiceName = pszVoice;
+ // Register callbacks that route audio and events back to CTTSEngine
+ m_rustTts->SetOnAudio([this](const uint8_t* data, uint32_t len) {
+ std::lock_guard lock(m_outputSiteMutex);
+ if (m_pOutputSite && len > 0)
+ {
+ // Write audio to the SAPI output site
+ OnAudioData(const_cast(data), len);
+ }
+ });
- DWORD dwValue = 0;
- if (!CheckHrNotFound(pConfigKey->GetDWORD(L"IsEdgeVoice", &dwValue)))
- m_isEdgeVoice = dwValue;
-
- if (CSpDynamicString pszWebsocketUrl; !CheckHrNotFound(pConfigKey->GetStringValue(L"WebsocketURL", &pszWebsocketUrl)))
- {
- CheckHrNotFound(pConfigKey->GetStringValue(L"Key", &pszKey));
- m_restApi->SetWebsocketUrl(
- pszKey ? WStringToUTF8(pszKey.m_psz) : "",
- WStringToUTF8(pszWebsocketUrl.m_psz)
- );
- }
- else if (CSpDynamicString pszRegion; !CheckHrNotFound(pConfigKey->GetStringValue(L"Region", &pszRegion)))
- {
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"Key", &pszKey)))
- return false;
- m_restApi->SetSubscription(
- WStringToUTF8(pszKey.m_psz),
- WStringToUTF8(pszRegion.m_psz)
- );
- }
- else
- return false;
+ m_rustTts->SetOnBoundary([this](const char* word, int32_t charOffset,
+ int32_t charLen, float startS, float endS) {
+ // Skip events with invalid offsets
+ if (charOffset < 0 || charLen <= 0)
+ return;
- LogInfo("Cloud voice (Rest API) created: {}", pszVoice.m_psz);
- return true;
-}
+ // Translate plain-text offsets (from Rust) → SAPI source offsets
+ ULONG sapiOffset = TranslateOffset(static_cast(charOffset));
-bool CTTSEngine::InitGenericHttpVoice(ISpDataKey* pConfigKey)
-{
- // Check for engine types that use generic HTTP TTS
- CSpDynamicString pszEngineType;
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"EngineType", &pszEngineType)))
- return false;
+ LogInfo("RustTts boundary: word='{}' plainOffset={} sapiOffset={} len={} startS={:.3f}",
+ word ? word : "(null)", charOffset, sapiOffset, charLen, startS);
- std::string engineType = pszEngineType.m_psz ? WStringToUTF8(std::wstring(pszEngineType.m_psz)) : "";
+ uint64_t offsetTicks = static_cast(startS * 1e7);
+ ULONGLONG audioBytes = WaveTicksToBytes(offsetTicks);
- // Azure/Edge are handled by the existing REST API path
- if (engineType == "Azure" || engineType == "Edge" || engineType == "Sherpa")
- return false;
+ std::lock_guard lock(m_outputSiteMutex);
+ if (!m_pOutputSite) return;
- static const std::set supportedEngines = {"OpenAI", "ElevenLabs", "Google", "Cartesia", "Deepgram"};
- if (supportedEngines.find(engineType) == supportedEngines.end())
- return false;
+ SPEVENT ev;
+ ZeroMemory(&ev, sizeof(ev));
+ ev.ullAudioStreamOffset = audioBytes;
+ ev.eEventId = SPEI_WORD_BOUNDARY;
+ ev.elParamType = SPET_LPARAM_IS_UNDEFINED;
+ ev.lParam = static_cast(sapiOffset);
+ ev.wParam = static_cast(charLen);
+ m_pOutputSite->AddEvents(&ev, 1);
+ });
- CSpDynamicString pszVoice, pszKey, pszRegion;
- if (CheckHrNotFound(pConfigKey->GetStringValue(L"Voice", &pszVoice)))
- return false;
- CheckHrNotFound(pConfigKey->GetStringValue(L"Key", &pszKey));
- CheckHrNotFound(pConfigKey->GetStringValue(L"Region", &pszRegion));
+ m_rustTts->SetOnViseme([this](int32_t visemeId, float offsetS) {
+ uint64_t offsetTicks = static_cast(offsetS * 1e7);
+ OnViseme(offsetTicks, static_cast(visemeId));
+ });
- m_genericTts = std::make_unique();
- m_genericTts->SetEngine(
- engineType,
- pszKey.m_psz ? WStringToUTF8(std::wstring(pszKey.m_psz)) : "",
- pszVoice.m_psz ? WStringToUTF8(std::wstring(pszVoice.m_psz)) : "",
- pszRegion.m_psz ? WStringToUTF8(std::wstring(pszRegion.m_psz)) : ""
- );
+ m_rustTts->SetOnError([](const char* msg) {
+ LogErr("RustTts engine error: {}", msg ? msg : "(null)");
+ });
- m_onlineVoiceName = pszVoice;
- LogInfo("Generic HTTP voice created: {} / {}", engineType, pszVoice.m_psz);
+ m_onlineVoiceName = pszVoice.m_psz ? pszVoice.m_psz : L"";
+ m_rustTtsUseSsml = false; // All engines use plain text — Rust builds SSML internally
+ LogInfo("RustTts voice created: {} / {}", engineType, pszVoice.m_psz ? pszVoice.m_psz : L"(default)");
return true;
}
@@ -1034,6 +720,7 @@ int CTTSEngine::OnAudioData(uint8_t* data, uint32_t len)
}
HRESULT hr = m_pOutputSite->Write(data, len - m_lastSilentBytes, &written);
+
// Assumes that the data can be either entirely written or not written at all
// because some implementations do not set the written bytes correctly
if (SUCCEEDED(hr))
@@ -1070,20 +757,6 @@ void CTTSEngine::OnBoundary(uint64_t audioOffsetTicks, uint32_t textOffset, uint
ev.lParam = offset;
ev.wParam = length;
m_pOutputSite->AddEvents(&ev, 1);
-
- if (m_isEdgeVoice && boundaryType == SPEI_WORD_BOUNDARY)
- {
- // trigger every untriggered bookmark until current word's end position
- auto size = m_bookmarks.size();
- while (m_bookmarkIndex < size)
- {
- auto& bookmark = m_bookmarks[m_bookmarkIndex];
- if (offset + length <= bookmark.ulSAPITextOffset)
- break;
- OnBookmark(audioOffsetTicks, bookmark.name);
- m_bookmarkIndex++;
- }
- }
}
void CTTSEngine::OnViseme(uint64_t offsetTicks, uint32_t visemeId)
{
@@ -1099,65 +772,6 @@ void CTTSEngine::OnViseme(uint64_t offsetTicks, uint32_t visemeId)
m_pOutputSite->AddEvents(&ev, 1);
}
-void CTTSEngine::SetupSynthesizerEvents(ULONGLONG interests)
-{
- ClearSynthesizerEvents();
-
- m_synthesizer->SynthesisStarted += [this](const SpeechSynthesisEventArgs&)
- {
- m_synthesizerStarted.store(true, std::memory_order_relaxed);
- };
- m_synthesizerStarted.store(false, std::memory_order_relaxed);
-
- if (interests & SVEBookmark)
- m_synthesizer->BookmarkReached += [this](const SpeechSynthesisBookmarkEventArgs& arg)
- {
- OnBookmark(arg.AudioOffset, UTF8ToWString(arg.Text));
- };
-
- if (interests & (SVEWordBoundary | SVESentenceBoundary))
- m_synthesizer->WordBoundary += [this](const SpeechSynthesisWordBoundaryEventArgs& arg)
- {
- if (arg.BoundaryType == SpeechSynthesisBoundaryType::Punctuation)
- return;
- OnBoundary(arg.AudioOffset, arg.TextOffset, arg.WordLength,
- arg.BoundaryType == SpeechSynthesisBoundaryType::Sentence ? SPEI_SENTENCE_BOUNDARY : SPEI_WORD_BOUNDARY);
- };
-
- if (interests & SVEViseme)
- m_synthesizer->VisemeReceived += [this](const SpeechSynthesisVisemeEventArgs& arg)
- {
- OnViseme(arg.AudioOffset, arg.VisemeId);
- };
-}
-
-void CTTSEngine::ClearSynthesizerEvents()
-{
- if (!m_synthesizer)
- return;
-
- m_synthesizer->BookmarkReached.DisconnectAll();
- m_synthesizer->WordBoundary.DisconnectAll();
- m_synthesizer->VisemeReceived.DisconnectAll();
- m_synthesizer->SynthesisStarted.DisconnectAll();
- m_synthesizer->SynthesisCompleted.DisconnectAll();
- m_synthesizer->SynthesisCanceled.DisconnectAll();
-}
-
-void CTTSEngine::SetupRestAPIEvents(ULONGLONG interests)
-{
- m_restApi->AudioReceivedCallback = std::bind_front(&CTTSEngine::OnAudioData, this);
- if (interests & SVEBookmark)
- m_restApi->BookmarkCallback = [this](auto a, auto b) { OnBookmark(a, UTF8ToWString(b)); };
- if ((interests & SVEWordBoundary)
- || (m_isEdgeVoice && (interests & SVEBookmark))) // Edge voice's bookmarks require word boundary events
- m_restApi->WordBoundaryCallback = [this](auto a, auto b, auto c) { OnBoundary(a, b, c, SPEI_WORD_BOUNDARY); };
- if (interests & SVESentenceBoundary)
- m_restApi->SentenceBoundaryCallback = [this](auto a, auto b, auto c) { OnBoundary(a, b, c, SPEI_SENTENCE_BOUNDARY); };
- if (interests & SVEViseme)
- m_restApi->VisemeCallback = std::bind_front(&CTTSEngine::OnViseme, this);
-}
-
void CTTSEngine::AppendTextFragToSsml(const SPVTEXTFRAG* pTextFrag)
{
// entities are converted to characters before passing in, so we have to convert it back to XML
@@ -1749,71 +1363,6 @@ std::wstring CTTSEngine::StripSSML(const std::wstring& ssml)
return result.substr(start, end - start + 1);
}
-void CTTSEngine::GenerateSherpaOnnxAudio(const std::string& plainText)
-{
- if (plainText.empty())
- {
- LogWarn("SherpaOnnx: No text to speak");
- return;
- }
-
- try
- {
- if (m_sherpaAbortRequested.load(std::memory_order_relaxed))
- {
- LogInfo("SherpaOnnx generation aborted");
- return;
- }
-
- // Serialize calls into shared Sherpa engine instance.
- std::vector samples;
- {
- std::lock_guard guard(g_sherpaGenerateMutex);
- // Baseline stable path: generate full audio first, then stream to SAPI output site.
- LogInfo("SherpaOnnx: Generate() call begin");
- samples = m_sherpaOnnx->Generate(plainText, 1.0f);
- LogInfo("SherpaOnnx: Generate() call end");
- }
- if (samples.empty())
- {
- if (!m_sherpaAbortRequested.load(std::memory_order_relaxed))
- LogWarn("SherpaOnnx generated no audio for text: {}", plainText);
- return;
- }
-
- if (m_sherpaAbortRequested.load(std::memory_order_relaxed))
- {
- LogInfo("SherpaOnnx generation aborted");
- return;
- }
-
- std::vector pcmData;
- pcmData.reserve(samples.size() * 2);
- for (float s : samples)
- {
- float clamped = std::clamp(s, -1.0f, 1.0f);
- int16_t pcm = static_cast(clamped * 32767.0f);
- pcmData.push_back(static_cast(pcm & 0xFF));
- pcmData.push_back(static_cast((pcm >> 8) & 0xFF));
- }
-
- int wrote = OnAudioData(pcmData.data(), static_cast(pcmData.size()));
- LogInfo("SherpaOnnx: OnAudioData returned {}", wrote);
- if (wrote <= 0)
- {
- LogWarn("SherpaOnnx audio write failed for text: {}", plainText);
- return;
- }
-
- LogInfo("SherpaOnnx generated {} samples", samples.size());
- }
- catch (const std::exception& ex)
- {
- LogErr("SherpaOnnx generation failed: {}", ex.what());
- throw;
- }
-}
-
void CTTSEngine::FinishSimulatingBookmarkEvents(ULONGLONG streamOffset)
{
const auto size = m_bookmarks.size();
@@ -1895,14 +1444,3 @@ void CTTSEngine::MapTextOffset(ULONG& ulSSMLOffset, ULONG& ulTextLen)
}
// Checks the result from speech operation, and throws if error happened
-void CTTSEngine::CheckSynthesisResult(const std::shared_ptr& result)
-{
- if (result->Reason != ResultReason::Canceled)
- return;
-
- auto details = SpeechSynthesisCancellationDetails::FromResult(result);
- if (details->Reason != CancellationReason::Error)
- return;
-
- throw std::runtime_error(UTF8ToAnsi(details->ErrorDetails));
-}
diff --git a/VoiceGardenSAPIAdapter/TTSEngine.h b/VoiceGardenSAPIAdapter/TTSEngine.h
index f2ab389..107ee08 100644
--- a/VoiceGardenSAPIAdapter/TTSEngine.h
+++ b/VoiceGardenSAPIAdapter/TTSEngine.h
@@ -1,17 +1,14 @@
// TTSEngine.h: CTTSEngine 的声明
#pragma once
-#include "resource.h" // 主符号
-#include "GenericHttpTts.h"
+#include "resource.h"
#include "pch.h"
-#include
-#include "SpeechRestAPI.h"
+#include // needed for sapi_category in SapiException
#include "Logger.h"
#include "SapiException.h"
-#include "Mp3Decoder.h"
-#include "../SherpaOnnx/SherpaOnnxEngine.h"
+#include "RustTts/RustTtsEngine.h"
#include "VoiceGardenSAPIAdapter_i.h"
@@ -99,15 +96,15 @@ END_COM_MAP()
private:
struct TextOffsetMapping
{
- ULONG ulSAPITextOffset; // offset in source string from SAPI
- ULONG ulSSMLTextOffset; // offset in our SSML buffer
+ ULONG ulSAPITextOffset;
+ ULONG ulSSMLTextOffset;
constexpr TextOffsetMapping(ULONG ulSAPITextOffset, ULONG ulSSMLTextOffset) noexcept
: ulSAPITextOffset(ulSAPITextOffset), ulSSMLTextOffset(ulSSMLTextOffset) {}
};
struct BookmarkInfo
{
- ULONG ulSAPITextOffset; // offset in source string from SAPI
+ ULONG ulSAPITextOffset;
std::wstring name;
constexpr BookmarkInfo(ULONG ulSAPITextOffset, std::wstring name) noexcept
: ulSAPITextOffset(ulSAPITextOffset), name(name) {}
@@ -121,18 +118,29 @@ END_COM_MAP()
CComPtr m_cpToken;
CComPtr m_phoneConverter;
- std::shared_ptr m_synthesizer;
- std::unique_ptr m_restApi;
- std::shared_ptr m_sherpaOnnx;
- std::unique_ptr m_genericTts;
+ std::unique_ptr m_rustTts;
+ bool m_rustTtsUseSsml = false;
std::future m_lastCancellingFuture;
+ // Boundary events queued during synthesis, delivered via SAPI AddEvents
+ struct PendingBoundary {
+ ULONGLONG audioByteOffset;
+ ULONG textOffset;
+ ULONG textLen;
+ };
+ std::vector m_pendingBoundaries;
+ size_t m_boundaryIndex = 0;
+ ULONGLONG m_totalAudioBytesWritten = 0;
+
+ // Maps plain text character positions → SAPI source text positions.
+ // Populated by ExtractSherpaPlainTextWithMap(). Used by boundary
+ // callback to translate Rust's plain-text offsets back to SAPI offsets.
+ std::vector m_plainToSapiMap;
+
ErrorMode m_errorMode = ErrorMode::ProbeForError;
bool m_isEdgeVoice = false;
- bool m_isSherpaOnnxVoice = false;
bool m_onlineDelayOptimization = false;
bool m_compensatedSilenceWritten = false;
- std::atomic_bool m_synthesizerStarted = false;
std::atomic_bool m_sherpaAbortRequested = false;
ULONG m_lastSilentBytes = 0;
ULONG m_compensatedSilentBytes = 0;
@@ -144,16 +152,9 @@ END_COM_MAP()
std::wstring m_ssml; // translated SSML
- // SAPI XML will be translated into SSML,
- // but we need to keep track of the original text offsets
- // so that events like WordBoundary still work
std::vector m_offsetMappings;
-
size_t m_mappingIndex = 0;
- // Edge voices do not support bookmarks.
- // We store the specified bookmark positions,
- // and when a word boundary
std::vector m_bookmarks;
size_t m_bookmarkIndex = 0;
@@ -167,26 +168,24 @@ END_COM_MAP()
void InitPhoneConverter();
void InitVoice();
- bool InitLocalVoice(ISpDataKey* pConfigKey);
- bool InitSherpaOnnxVoice(ISpDataKey* pConfigKey);
- bool InitCloudVoiceSynthesizer(ISpDataKey* pConfigKey);
- bool InitCloudVoiceRestAPI(ISpDataKey* pConfigKey);
- bool InitGenericHttpVoice(ISpDataKey* pConfigKey);
- void SetupSynthesizerEvents(ULONGLONG interests);
- void ClearSynthesizerEvents();
- void SetupRestAPIEvents(ULONGLONG interests);
+ bool InitRustTtsVoice(ISpDataKey* pConfigKey);
+
+ void FinishSimulatingBookmarkEvents(ULONGLONG streamOffset);
void AppendTextFragToSsml(const SPVTEXTFRAG* pTextFrag);
void AppendPhonemesToSsml(const SPPHONEID* pPhoneIds);
void AppendSAPIContextToSsml(const SPVCONTEXT& context);
bool BuildSSML(const SPVTEXTFRAG* pTextFragList);
std::wstring StripSSML(const std::wstring& ssml);
- void GenerateSherpaOnnxAudio(const std::string& plainText);
- void FinishSimulatingBookmarkEvents(ULONGLONG streamOffset);
+ /// Extract plain text from SAPI fragments AND build a character position
+ /// map (plain text pos → SAPI source pos) for boundary event translation.
+ std::wstring ExtractSherpaPlainTextWithMap(const SPVTEXTFRAG* pTextFragList);
+
+ /// Translate a plain-text character offset to SAPI source offset.
+ ULONG TranslateOffset(ULONG plainOffset) const;
void MapTextOffset(ULONG& ulSSMLOffset, ULONG& ulTextLen);
- void CheckSynthesisResult(const std::shared_ptr& result);
template
HRESULT OnException(
@@ -225,9 +224,7 @@ HRESULT CTTSEngine::OnException(
{
auto& cat = ex.code().category();
if (cat != std::system_category()
- && cat != sapi_category()
- && cat != azac_category()
- && cat != mci_category())
+ && cat != sapi_category())
wmsg.insert(0, L"Network connection error:\r\n\r\n");
}
MessageBoxW(NULL, wmsg.c_str(), L"VoiceGardenSAPIAdapter", MB_ICONEXCLAMATION | MB_SYSTEMMODAL);
diff --git a/VoiceGardenSAPIAdapter/VoiceGardenSAPIAdapter.vcxproj b/VoiceGardenSAPIAdapter/VoiceGardenSAPIAdapter.vcxproj
index 2993742..9fe3db8 100644
--- a/VoiceGardenSAPIAdapter/VoiceGardenSAPIAdapter.vcxproj
+++ b/VoiceGardenSAPIAdapter/VoiceGardenSAPIAdapter.vcxproj
@@ -1,576 +1,558 @@
-
-
-
-
- Debug
- ARM64
-
-
- Debug
- Win32
-
-
- Release
- ARM64
-
-
- Release
- Win32
-
-
- Debug
- x64
-
-
- Release
- x64
-
-
-
- 17.0
- {B06628FA-7189-4035-A347-C619A2D1638B}
- AtlProj
- 10.0
-
-
-
- DynamicLibrary
- true
- v143
- Unicode
- YY_Thunks_for_WinXP.obj
-
-
- DynamicLibrary
- false
- v143
- Unicode
- YY_Thunks_for_WinXP.obj
- true
-
-
- DynamicLibrary
- true
- v143
- Unicode
- YY_Thunks_for_WinXP.obj
-
-
- DynamicLibrary
- true
- v143
- Unicode
- false
-
-
- DynamicLibrary
- false
- v143
- Unicode
- YY_Thunks_for_WinXP.obj
- true
-
-
- DynamicLibrary
- false
- v143
- Unicode
- false
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- true
-
-
- true
- true
- $(SolutionDir)include;$(ExternalIncludePath)
- $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
-
-
- true
- true
- $(SolutionDir)include;$(ExternalIncludePath)
- $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
-
-
- true
- true
- $(SolutionDir)include;$(ExternalIncludePath)
- $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
-
-
- true
- false
- $(SolutionDir)include;$(ExternalIncludePath)
- $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
- true
-
-
- true
- false
- $(SolutionDir)include;$(ExternalIncludePath)
- $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
- true
-
-
- true
- false
- $(SolutionDir)include;$(ExternalIncludePath)
- $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
- true
-
-
-
- Use
- Disabled
- _WINDOWS;_DEBUG;_USRDLL;%(PreprocessorDefinitions)
- pch.h
- stdcpplatest
- /Zc:__cplusplus %(AdditionalOptions)
- MultiThreadedDebug
- true
- TurnOffAllWarnings
- true
- Level4
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
-
-
- false
- _DEBUG;%(PreprocessorDefinitions)
- VoiceGardenSAPIAdapter_i.h
- VoiceGardenSAPIAdapter_i.c
- VoiceGardenSAPIAdapter_p.c
- true
- $(IntDir)VoiceGardenSAPIAdapter.tlb
-
- true
-
-
- 0x0804
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
- _DEBUG;%(PreprocessorDefinitions)
-
-
- Windows
- .\VoiceGardenSAPIAdapter.def
- $(RegisterOutput)
- false
- Microsoft.CognitiveServices.Speech.core.dll
- libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
- ..\lib\x64;%(AdditionalLibraryDirectories)
-
-
- copy /Y "..\SherpaOnnx\dlls\x64\Debug\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x64\Debug\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x64\Debug\onnxruntime_providers_shared.dll" "$(OutDir)"
-
-
-
-
- Use
- Disabled
- _WINDOWS;_DEBUG;_USRDLL;%(PreprocessorDefinitions)
- pch.h
- stdcpplatest
- /Zc:__cplusplus %(AdditionalOptions)
- MultiThreadedDebug
- true
- TurnOffAllWarnings
- true
- Level4
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-arm64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
-
-
- false
- _DEBUG;%(PreprocessorDefinitions)
- VoiceGardenSAPIAdapter_i.h
- VoiceGardenSAPIAdapter_i.c
- VoiceGardenSAPIAdapter_p.c
- true
- $(IntDir)VoiceGardenSAPIAdapter.tlb
-
-
- true
-
-
- 0x0804
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-arm64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
- _DEBUG;%(PreprocessorDefinitions)
-
-
- Windows
- .\VoiceGardenSAPIAdapter.def
- $(RegisterOutput)
- false
- Microsoft.CognitiveServices.Speech.core.dll
- libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
- ..\lib\arm64;%(AdditionalLibraryDirectories)
-
-
- copy /Y "..\SherpaOnnx\dlls\ARM64\Debug\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\ARM64\Debug\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\ARM64\Debug\onnxruntime_providers_shared.dll" "$(OutDir)"
-
-
-
-
- Use
- Disabled
- WIN32;_WINDOWS;_DEBUG;_USRDLL;%(PreprocessorDefinitions)
- pch.h
- stdcpplatest
- /Zc:__cplusplus %(AdditionalOptions)
- MultiThreadedDebug
- true
- TurnOffAllWarnings
- true
- Level4
- NoExtensions
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x86-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
-
-
- false
- Win32
- _DEBUG;%(PreprocessorDefinitions)
- VoiceGardenSAPIAdapter_i.h
- VoiceGardenSAPIAdapter_i.c
- VoiceGardenSAPIAdapter_p.c
- true
- $(IntDir)VoiceGardenSAPIAdapter.tlb
-
- true
-
-
- 0x0804
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x86-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
- _DEBUG;%(PreprocessorDefinitions)
-
-
- Windows
- .\VoiceGardenSAPIAdapter.def
- $(RegisterOutput)
- false
- Microsoft.CognitiveServices.Speech.core.dll
- libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
- ..\lib\x86;%(AdditionalLibraryDirectories)
-
-
- copy /Y "..\SherpaOnnx\dlls\x86\Debug\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x86\Debug\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x86\Debug\onnxruntime_providers_shared.dll" "$(OutDir)"
-
-
-
-
- Use
- MaxSpeed
- WIN32;_WINDOWS;NDEBUG;_USRDLL;%(PreprocessorDefinitions)
- pch.h
- stdcpplatest
- /Zc:__cplusplus %(AdditionalOptions)
- MultiThreaded
- true
- TurnOffAllWarnings
- true
- Level4
- NoExtensions
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x86-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
-
-
- false
- Win32
- NDEBUG;%(PreprocessorDefinitions)
- VoiceGardenSAPIAdapter_i.h
- VoiceGardenSAPIAdapter_i.c
- VoiceGardenSAPIAdapter_p.c
- true
- $(IntDir)VoiceGardenSAPIAdapter.tlb
-
- true
-
-
- 0x0804
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x86-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
- NDEBUG;%(PreprocessorDefinitions)
-
-
- Windows
- .\VoiceGardenSAPIAdapter.def
- true
- true
- $(RegisterOutput)
- false
- Microsoft.CognitiveServices.Speech.core.dll
- /PDBALTPATH:%_PDB% %(AdditionalOptions)
- libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
- ..\lib\x86;%(AdditionalLibraryDirectories)
-
-
- copy /Y "..\SherpaOnnx\dlls\x86\Release\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x86\Release\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x86\Release\onnxruntime_providers_shared.dll" "$(OutDir)"
-
-
-
-
- Use
- MaxSpeed
- _WINDOWS;NDEBUG;_USRDLL;%(PreprocessorDefinitions)
- pch.h
- stdcpplatest
- /Zc:__cplusplus %(AdditionalOptions)
- MultiThreaded
- true
- TurnOffAllWarnings
- true
- Level4
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
-
-
- false
- NDEBUG;%(PreprocessorDefinitions)
- VoiceGardenSAPIAdapter_i.h
- VoiceGardenSAPIAdapter_i.c
- VoiceGardenSAPIAdapter_p.c
- true
- $(IntDir)VoiceGardenSAPIAdapter.tlb
-
- true
-
-
- 0x0804
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
- NDEBUG;%(PreprocessorDefinitions)
-
-
- Windows
- .\VoiceGardenSAPIAdapter.def
- true
- true
- $(RegisterOutput)
- false
- Microsoft.CognitiveServices.Speech.core.dll
- /PDBALTPATH:%_PDB% %(AdditionalOptions)
- libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
- ..\lib\x64;%(AdditionalLibraryDirectories)
-
-
- copy /Y "..\SherpaOnnx\dlls\x64\Release\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x64\Release\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x64\Release\onnxruntime_providers_shared.dll" "$(OutDir)"
-
-
-
-
- Use
- MaxSpeed
- _WINDOWS;NDEBUG;_USRDLL;%(PreprocessorDefinitions)
- pch.h
- stdcpplatest
- /Zc:__cplusplus %(AdditionalOptions)
- MultiThreaded
- true
- TurnOffAllWarnings
- true
- Level4
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-arm64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
-
-
- false
- NDEBUG;%(PreprocessorDefinitions)
- VoiceGardenSAPIAdapter_i.h
- VoiceGardenSAPIAdapter_i.c
- VoiceGardenSAPIAdapter_p.c
- true
- $(IntDir)VoiceGardenSAPIAdapter.tlb
-
-
- true
-
-
- 0x0804
- $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-arm64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
- NDEBUG;%(PreprocessorDefinitions)
-
-
- Windows
- .\VoiceGardenSAPIAdapter.def
- true
- true
- $(RegisterOutput)
- false
- Microsoft.CognitiveServices.Speech.core.dll
- /PDBALTPATH:%_PDB% %(AdditionalOptions)
- libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
- ..\lib\arm64;%(AdditionalLibraryDirectories)
-
-
- copy /Y "..\SherpaOnnx\dlls\ARM64\Release\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\ARM64\Release\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\ARM64\Release\onnxruntime_providers_shared.dll" "$(OutDir)"
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- false
- false
- false
- false
- false
- false
-
-
-
-
-
-
- false
- false
- false
- false
- false
- false
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Create
- Create
- Create
- Create
- Create
- Create
-
-
- /bigobj %(AdditionalOptions)
- /bigobj %(AdditionalOptions)
- /bigobj %(AdditionalOptions)
- /bigobj %(AdditionalOptions)
- /bigobj %(AdditionalOptions)
- /bigobj %(AdditionalOptions)
-
-
-
-
-
-
- NotUsing
- NotUsing
- NotUsing
- NotUsing
- NotUsing
- NotUsing
-
-
- NotUsing
- NotUsing
- NotUsing
- NotUsing
- NotUsing
- NotUsing
-
-
- NotUsing
- NotUsing
- NotUsing
- NotUsing
- NotUsing
- NotUsing
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 这台计算机上缺少此项目引用的 NuGet 程序包。使用“NuGet 程序包还原”可下载这些程序包。有关更多信息,请参见 http://go.microsoft.com/fwlink/?LinkID=322105。缺少的文件是 {0}。
-
-
-
-
-
-
-
-
+
+
+
+
+ Debug
+ ARM64
+
+
+ Debug
+ Win32
+
+
+ Release
+ ARM64
+
+
+ Release
+ Win32
+
+
+ Debug
+ x64
+
+
+ Release
+ x64
+
+
+
+ 17.0
+ {B06628FA-7189-4035-A347-C619A2D1638B}
+ AtlProj
+ 10.0
+
+
+
+ DynamicLibrary
+ true
+ v143
+ Unicode
+ YY_Thunks_for_WinXP.obj
+
+
+ DynamicLibrary
+ false
+ v143
+ Unicode
+ YY_Thunks_for_WinXP.obj
+ true
+
+
+ DynamicLibrary
+ true
+ v143
+ Unicode
+ YY_Thunks_for_WinXP.obj
+
+
+ DynamicLibrary
+ true
+ v143
+ Unicode
+ false
+
+
+ DynamicLibrary
+ false
+ v143
+ Unicode
+ YY_Thunks_for_WinXP.obj
+ true
+
+
+ DynamicLibrary
+ false
+ v143
+ Unicode
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+ true
+ true
+ $(SolutionDir)include;$(ExternalIncludePath)
+ $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
+
+
+ true
+ true
+ $(SolutionDir)include;$(ExternalIncludePath)
+ $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
+
+
+ true
+ true
+ $(SolutionDir)include;$(ExternalIncludePath)
+ $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
+
+
+ true
+ false
+ $(SolutionDir)include;$(ExternalIncludePath)
+ $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
+ true
+
+
+ true
+ false
+ $(SolutionDir)include;$(ExternalIncludePath)
+ $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
+ true
+
+
+ true
+ false
+ $(SolutionDir)include;$(ExternalIncludePath)
+ $(SolutionDir)lib\$(PlatformShortName);$(LibraryPath)
+ true
+
+
+
+ Use
+ Disabled
+ _WINDOWS;_DEBUG;_USRDLL;%(PreprocessorDefinitions)
+ pch.h
+ stdcpplatest
+ /Zc:__cplusplus %(AdditionalOptions)
+ MultiThreadedDebug
+ true
+ TurnOffAllWarnings
+ true
+ Level4
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+
+
+ false
+ _DEBUG;%(PreprocessorDefinitions)
+ VoiceGardenSAPIAdapter_i.h
+ VoiceGardenSAPIAdapter_i.c
+ VoiceGardenSAPIAdapter_p.c
+ true
+ $(IntDir)VoiceGardenSAPIAdapter.tlb
+
+ true
+
+
+ 0x0804
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+ _DEBUG;%(PreprocessorDefinitions)
+
+
+ Windows
+ .\VoiceGardenSAPIAdapter.def
+ $(RegisterOutput)
+ false
+ Microsoft.CognitiveServices.Speech.core.dll
+ libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
+ ..\lib\x64;%(AdditionalLibraryDirectories)
+
+
+ copy /Y "..\SherpaOnnx\dlls\x64\Debug\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x64\Debug\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x64\Debug\onnxruntime_providers_shared.dll" "$(OutDir)"
+
+
+
+
+ Use
+ Disabled
+ _WINDOWS;_DEBUG;_USRDLL;%(PreprocessorDefinitions)
+ pch.h
+ stdcpplatest
+ /Zc:__cplusplus %(AdditionalOptions)
+ MultiThreadedDebug
+ true
+ TurnOffAllWarnings
+ true
+ Level4
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-arm64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+
+
+ false
+ _DEBUG;%(PreprocessorDefinitions)
+ VoiceGardenSAPIAdapter_i.h
+ VoiceGardenSAPIAdapter_i.c
+ VoiceGardenSAPIAdapter_p.c
+ true
+ $(IntDir)VoiceGardenSAPIAdapter.tlb
+
+
+ true
+
+
+ 0x0804
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-arm64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+ _DEBUG;%(PreprocessorDefinitions)
+
+
+ Windows
+ .\VoiceGardenSAPIAdapter.def
+ $(RegisterOutput)
+ false
+ Microsoft.CognitiveServices.Speech.core.dll
+ libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
+ ..\lib\arm64;%(AdditionalLibraryDirectories)
+
+
+ copy /Y "..\SherpaOnnx\dlls\ARM64\Debug\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\ARM64\Debug\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\ARM64\Debug\onnxruntime_providers_shared.dll" "$(OutDir)"
+
+
+
+
+ Use
+ Disabled
+ WIN32;_WINDOWS;_DEBUG;_USRDLL;%(PreprocessorDefinitions)
+ pch.h
+ stdcpplatest
+ /Zc:__cplusplus %(AdditionalOptions)
+ MultiThreadedDebug
+ true
+ TurnOffAllWarnings
+ true
+ Level4
+ NoExtensions
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x86-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+
+
+ false
+ Win32
+ _DEBUG;%(PreprocessorDefinitions)
+ VoiceGardenSAPIAdapter_i.h
+ VoiceGardenSAPIAdapter_i.c
+ VoiceGardenSAPIAdapter_p.c
+ true
+ $(IntDir)VoiceGardenSAPIAdapter.tlb
+
+ true
+
+
+ 0x0804
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x86-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+ _DEBUG;%(PreprocessorDefinitions)
+
+
+ Windows
+ .\VoiceGardenSAPIAdapter.def
+ $(RegisterOutput)
+ false
+ Microsoft.CognitiveServices.Speech.core.dll
+ libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
+ ..\lib\x86;%(AdditionalLibraryDirectories)
+
+
+ copy /Y "..\SherpaOnnx\dlls\x86\Debug\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x86\Debug\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x86\Debug\onnxruntime_providers_shared.dll" "$(OutDir)"
+
+
+
+
+ Use
+ MaxSpeed
+ WIN32;_WINDOWS;NDEBUG;_USRDLL;%(PreprocessorDefinitions)
+ pch.h
+ stdcpplatest
+ /Zc:__cplusplus %(AdditionalOptions)
+ MultiThreaded
+ true
+ TurnOffAllWarnings
+ true
+ Level4
+ NoExtensions
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x86-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+
+
+ false
+ Win32
+ NDEBUG;%(PreprocessorDefinitions)
+ VoiceGardenSAPIAdapter_i.h
+ VoiceGardenSAPIAdapter_i.c
+ VoiceGardenSAPIAdapter_p.c
+ true
+ $(IntDir)VoiceGardenSAPIAdapter.tlb
+
+ true
+
+
+ 0x0804
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x86-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+ NDEBUG;%(PreprocessorDefinitions)
+
+
+ Windows
+ .\VoiceGardenSAPIAdapter.def
+ true
+ true
+ $(RegisterOutput)
+ false
+ Microsoft.CognitiveServices.Speech.core.dll
+ /PDBALTPATH:%_PDB% %(AdditionalOptions)
+ libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
+ ..\lib\x86;%(AdditionalLibraryDirectories)
+
+
+ copy /Y "..\SherpaOnnx\dlls\x86\Release\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x86\Release\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x86\Release\onnxruntime_providers_shared.dll" "$(OutDir)"
+
+
+
+
+ Use
+ MaxSpeed
+ _WINDOWS;NDEBUG;_USRDLL;%(PreprocessorDefinitions)
+ pch.h
+ stdcpplatest
+ /Zc:__cplusplus %(AdditionalOptions)
+ MultiThreaded
+ true
+ TurnOffAllWarnings
+ true
+ Level4
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+
+
+ false
+ NDEBUG;%(PreprocessorDefinitions)
+ VoiceGardenSAPIAdapter_i.h
+ VoiceGardenSAPIAdapter_i.c
+ VoiceGardenSAPIAdapter_p.c
+ true
+ $(IntDir)VoiceGardenSAPIAdapter.tlb
+
+ true
+
+
+ 0x0804
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-x64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+ NDEBUG;%(PreprocessorDefinitions)
+
+
+ Windows
+ .\VoiceGardenSAPIAdapter.def
+ true
+ true
+ $(RegisterOutput)
+ false
+ Microsoft.CognitiveServices.Speech.core.dll
+ /PDBALTPATH:%_PDB% %(AdditionalOptions)
+ libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
+ ..\lib\x64;%(AdditionalLibraryDirectories)
+
+
+ copy /Y "..\SherpaOnnx\dlls\x64\Release\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x64\Release\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\x64\Release\onnxruntime_providers_shared.dll" "$(OutDir)"
+
+
+
+
+ Use
+ MaxSpeed
+ _WINDOWS;NDEBUG;_USRDLL;%(PreprocessorDefinitions)
+ pch.h
+ stdcpplatest
+ /Zc:__cplusplus %(AdditionalOptions)
+ MultiThreaded
+ true
+ TurnOffAllWarnings
+ true
+ Level4
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-arm64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+
+
+ false
+ NDEBUG;%(PreprocessorDefinitions)
+ VoiceGardenSAPIAdapter_i.h
+ VoiceGardenSAPIAdapter_i.c
+ VoiceGardenSAPIAdapter_p.c
+ true
+ $(IntDir)VoiceGardenSAPIAdapter.tlb
+
+
+ true
+
+
+ 0x0804
+ $(IntDir);..\include;..\SherpaOnnx\libs\sherpa-onnx-v1.12.23-win-arm64-shared-$(Configuration)\include;%(AdditionalIncludeDirectories)
+ NDEBUG;%(PreprocessorDefinitions)
+
+
+ Windows
+ .\VoiceGardenSAPIAdapter.def
+ true
+ true
+ $(RegisterOutput)
+ false
+ Microsoft.CognitiveServices.Speech.core.dll
+ /PDBALTPATH:%_PDB% %(AdditionalOptions)
+ libcrypto.lib;libssl.lib;crypt32.lib;%(AdditionalDependencies)
+ ..\lib\arm64;%(AdditionalLibraryDirectories)
+
+
+ copy /Y "..\SherpaOnnx\dlls\ARM64\Release\sherpa-onnx-c-api.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\ARM64\Release\onnxruntime.dll" "$(OutDir)" & copy /Y "..\SherpaOnnx\dlls\ARM64\Release\onnxruntime_providers_shared.dll" "$(OutDir)"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ false
+ false
+ false
+ false
+ false
+
+
+
+
+
+ false
+ false
+ false
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create
+ Create
+ Create
+ Create
+ Create
+ Create
+
+
+
+
+
+
+
+ NotUsing
+ NotUsing
+ NotUsing
+ NotUsing
+ NotUsing
+ NotUsing
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 这台计算机上缺少此项目引用的 NuGet 程序包。使用“NuGet 程序包还原”可下载这些程序包。有关更多信息,请参见 http://go.microsoft.com/fwlink/?LinkID=322105。缺少的文件是 {0}。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VoiceGardenSAPIAdapter/VoiceGardenSAPIAdapter.vcxproj.filters b/VoiceGardenSAPIAdapter/VoiceGardenSAPIAdapter.vcxproj.filters
index bb3460f..be662e1 100644
--- a/VoiceGardenSAPIAdapter/VoiceGardenSAPIAdapter.vcxproj.filters
+++ b/VoiceGardenSAPIAdapter/VoiceGardenSAPIAdapter.vcxproj.filters
@@ -44,6 +44,12 @@
{37d56345-4034-4b17-b3b8-154417d00e4e}
+
+ {a1b2c3d4-e5f6-7890-abcd-ef0123456789}
+
+
+ {b2c3d4e5-f6a7-8901-bcde-f01234567890}
+
@@ -70,6 +76,15 @@
Header Files\SAPI Classes
+
+ Header Files\RustTts
+
+
+ Header Files\RustTts
+
+
+ Header Files\RustTts
+
Header Files\SAPI Classes
@@ -141,6 +156,12 @@
Source Files\SAPI Classes
+
+ Source Files\RustTts
+
+
+ Source Files\RustTts
+
Source Files\Utils
diff --git a/VoiceGardenSAPIAdapter/VoiceTokenEnumerator.cpp b/VoiceGardenSAPIAdapter/VoiceTokenEnumerator.cpp
index 1c65066..1222de0 100644
--- a/VoiceGardenSAPIAdapter/VoiceTokenEnumerator.cpp
+++ b/VoiceGardenSAPIAdapter/VoiceTokenEnumerator.cpp
@@ -155,22 +155,9 @@ HRESULT CVoiceTokenEnumerator::FinalConstruct() noexcept
if (!key.GetDword(L"Disable"))
{
- if (!key.GetDword(L"NoNarratorVoices")
- && !IsRunningInWin11Narrator()
- && (IsWindows7OrGreater() // this requires Win 7
- || RegOpenConfigKey().GetDword(L"ForceEnableAzureSpeechSDK")))
- {
- // Use the same map, so that local voices with the same ID won't appear twice
- TokenMap tokens;
-
- if (!narratorVoicePath.empty())
- EnumLocalVoicesInFolder(tokens, narratorVoicePath.c_str(), errorMode);
-
- for (auto& token : tokens)
- {
- s_cachedTokens.push_back(std::move(token.second));
- }
- }
+ // Narrator natural voices are no longer supported — the original hack
+ // (extracting encryption keys from system files) is broken on modern
+ // Windows 11 builds. SherpaOnnx provides better offline alternatives.
TokenMap onlineTokens;
if (!key.GetDword(L"NoEdgeVoices"))
@@ -212,15 +199,19 @@ HRESULT CVoiceTokenEnumerator::FinalConstruct() noexcept
}
}
- if (!s_isCacheTaskScheduled)
+ // Protect the entire check-and-set sequence with mutex to prevent race condition
{
- s_isCacheTaskScheduled = true;
- g_taskScheduler.StartNewTask(10000, []()
- {
- std::lock_guard lock(s_cacheMutex);
- s_cachedTokens.clear();
- s_isCacheTaskScheduled = false;
- });
+ std::lock_guard checkLock(s_cacheMutex);
+ if (!s_isCacheTaskScheduled)
+ {
+ s_isCacheTaskScheduled = true;
+ g_taskScheduler.StartNewTask(10000, []()
+ {
+ std::lock_guard clearLock(s_cacheMutex);
+ s_cachedTokens.clear();
+ s_isCacheTaskScheduled = false;
+ });
+ }
}
for (auto& token : s_cachedTokens)
@@ -601,7 +592,8 @@ static std::shared_ptr MakeEdgeVoiceToken(
{ L"ErrorMode", std::to_wstring(static_cast(errorMode)) },
{ L"WebsocketURL", EDGE_WEBSOCKET_URL },
{ L"Voice", shortName },
- { L"IsEdgeVoice", L"1" }
+ { L"IsEdgeVoice", L"1" },
+ { L"EngineType", L"Edge" }
}
} }
}
diff --git a/VoiceGardenSAPIAdapter/WSConnectionPool.cpp b/VoiceGardenSAPIAdapter/WSConnectionPool.cpp
deleted file mode 100644
index d1bb7fa..0000000
--- a/VoiceGardenSAPIAdapter/WSConnectionPool.cpp
+++ /dev/null
@@ -1,449 +0,0 @@
-#include "pch.h"
-#include "WSConnectionPool.h"
-#include "RegKey.h"
-#include "SpeechServiceConstants.h"
-#include
-
-extern TaskScheduler g_taskScheduler;
-
-// Time delta between the remote server time and the local time.
-static std::atomic s_responseTimeDelta = 0;
-
-static void ReplaceString(std::string& str, std::string_view from, const std::string& to)
-{
- size_t pos = 0;
- while ((pos = str.find(from, pos)) != std::string::npos)
- {
- str.replace(pos, from.size(), to);
- pos += to.size();
- }
-}
-
-static std::string GetGECToken()
-{
- // algorithm for generating the Sec-MS-GEC token
- // see: https://github.com/rany2/edge-tts/issues/290#issuecomment-2464956570
-
- FILETIME ft;
- GetSystemTimeAsFileTime(&ft);
- ULARGE_INTEGER ul = { ft.dwLowDateTime, ft.dwHighDateTime };
- // add the time delta to the local time
- ul.QuadPart += s_responseTimeDelta.load(std::memory_order_relaxed);
- ul.QuadPart -= ul.QuadPart % 3'000'000'000; // round down to the nearest 5 minute
-
- // concatenate ticks & trusted client token into a string
- // then calculate SHA-256 hash
- std::string str = std::to_string(ul.QuadPart) + EDGE_TRUSTED_CLIENT_TOKEN;
- BYTE hash[SHA256_DIGEST_LENGTH];
- if (!SHA256(reinterpret_cast(str.data()), str.size(), hash))
- throw std::runtime_error("GEC token generation failed");
-
- // convert the hash to a hexadecimal string
- std::string result(SHA256_DIGEST_LENGTH * 2, '0');
- char* pCh = result.data();
- constexpr const char* HexDigits = "0123456789ABCDEF";
- for (BYTE byte : hash)
- {
- *pCh++ = HexDigits[(byte & 0xF0) >> 4];
- *pCh++ = HexDigits[byte & 0x0F];
- }
-
- return result;
-}
-
-WSConnectionPool::WSConnectionPool()
-{
- m_client.init_asio();
- m_client.set_tls_init_handler([](websocketpp::connection_hdl)
- {
- auto ctx = std::make_shared(asio::ssl::context::sslv23_client);
- return ctx;
- });
- m_asioThread = std::thread(std::bind(&WSConnectionPool::AsioThread, this));
-
- using namespace std::chrono;
- using namespace std::chrono_literals;
-
- auto key = RegOpenNetworkConfigKey();
-
- m_minCount = key.GetDword(L"ConnectionPoolMinCount", 1);
- m_maxCount = key.GetDword(L"ConnectionPoolMaxCount",
- std::max(10, m_minCount));
-
- m_keepAliveInterval = seconds(
- key.GetDword(L"ConnectionKeepAliveInterval",
- (DWORD)duration_cast(20s).count())
- );
- m_keepAliveDuration = seconds(
- key.GetDword(L"ConnectionKeepAliveDuration",
- (DWORD)duration_cast(0min).count())
- );
- m_maxConnectionAge = seconds(
- key.GetDword(L"ConnectionMaxAge",
- (DWORD)duration_cast(3min + 30s).count())
- );
-
- DWORD intervalMs = (DWORD)duration_cast(m_keepAliveInterval).count();
- if (intervalMs != 0 && m_keepAliveDuration != clock::duration::zero())
- {
- m_keepAliveTimer = g_taskScheduler.StartNewTask(
- intervalMs, intervalMs,
- std::bind_front(&WSConnectionPool::KeepConnectionsAlive, this)
- );
- }
-}
-
-WSConnectionPool::~WSConnectionPool()
-{
- // close all connections
- std::lock_guard outer_lock(m_mutex);
- for (auto& host : m_hosts)
- {
- std::lock_guard inner_lock(host.second.mutex);
- for (auto& conn : host.second.connections)
- {
- conn->terminate(std::error_code());
- }
- }
- m_hosts.clear();
- m_client.stop_perpetual();
- m_client.stop();
- if (m_asioThread.joinable())
- m_asioThread.join();
-
- if (m_keepAliveTimer)
- g_taskScheduler.CancelTask(m_keepAliveTimer, false);
-}
-
-void WSConnectionPool::AsioThread()
-{
- m_client.start_perpetual();
- for (;;) // allows IO loop to recover from exceptions thrown from handlers
- {
- try
- {
- m_client.run();
- break; // exit the thread on normal return
- }
- catch (const std::system_error& ex)
- {
- // log the exception, then rejoin the IO loop
- LogErr("Connection pool: {}", ex);
- }
- catch (const std::exception& ex)
- {
- // log the exception, then rejoin the IO loop
- LogErr("Connection pool: {}", ex);
- }
- }
-}
-
-// Check every connection in the list, and return the first open one
-WSConnectionPtr WSConnectionPool::GetConnection(HostInfo& info)
-{
- auto& conns = info.connections;
- WSConnectionPtr result = nullptr;
- const auto now = clock::now();
-
- auto it = std::find_if(conns.begin(), conns.end(),
- [](const WSConnectionUPtr& conn) { return conn->get_state() == websocketpp::session::state::open; });
-
- // choose the first working connection
- if (it != conns.end())
- {
- // take it out of the list, without decreasing the actual count
- result = MakeConnectionPtr(info, std::move(*it));
- conns.erase(it);
- info.lastActiveTime = clock::now();
- LogDebug("Connection pool: Connection {} taken from pool ({}/{})",
- result->m_conn, conns.size(), info.count);
- }
-
- return result;
-}
-
-// Take the first usable connection from the list. If there isn't any, wait for a new connection.
-WSConnectionPtr WSConnectionPool::TakeConnection(
- const std::string& url,
- const std::string& key,
- std::stop_token stop_token)
-{
- decltype(m_hosts)::iterator host_it;
-
- {
- HostId host_id(url, key);
- std::lock_guard lock(m_mutex);
- host_it = m_hosts.try_emplace(std::move(host_id)).first; // if not found, add a new host
- }
-
- auto& info = host_it->second;
-
- std::stop_callback stopCallback(stop_token, [&info]()
- {
- { std::lock_guard lock(info.mutex); } // lock and unlock
- info.connectionChanged.notify_all();
- });
-
- // Define unique_lock AFTER stop_callback.
- // This makes sure that the lock is unlocked BEFORE stop_callback is destroyed.
- // Because the destructor of stop_callback will wait for the callback to complete,
- // which can cause deadlocks.
- std::unique_lock lock(info.mutex);
-
- if (stop_token.stop_requested())
- return {};
-
- for (;;)
- {
- auto conn = GetConnection(info);
-
- // Replenish connections to the minimum count.
- // If there's no spare connection, add an extra one,
- // unless we are at the maxCount limit.
- const size_t replenishToCount = (conn == nullptr)
- ? std::clamp(info.count + 1, m_minCount, m_maxCount)
- : m_minCount;
- for (size_t n = info.count; n < replenishToCount; n++)
- {
- CreateConnection(url, key, info);
- }
-
- // Return the chosen connection, if there is one
- if (conn)
- return conn;
-
- // If there isn't, wait for new connections and try again
- LogDebug("Connection pool: Waiting for new connection...");
- info.connectionChanged.wait(lock);
- if (stop_token.stop_requested())
- return {};
- if (info.lastException)
- std::rethrow_exception(info.lastException);
- }
-}
-
-void WSConnectionPool::PutBackConnection(HostInfo& info, WSConnectionUPtr&& conn)
-{
- std::lock_guard lock(info.mutex);
- conn->set_message_handler(nullptr);
- conn->set_close_handler(nullptr);
- if (conn->get_state() == websocketpp::session::state::open)
- {
- LogDebug("Connection pool: Connection {} put back to pool ({}/{})",
- conn->m_conn, info.connections.size(), info.count);
- info.connections.push_back(std::move(conn)); // put at the end of the pool
- info.connectionChanged.notify_all();
- }
- else
- {
- info.count--;
- LogDebug("Connection pool: Connection {} closed and dropped ({}/{})",
- conn->m_conn, info.connections.size(), info.count);
-
- // remove it (reset its handlers) instead of putting it back to the pool
- // connection count won't be decreased again, because it's not in the pool
- RemoveConnection(info, conn.get());
- conn.reset();
- }
-}
-
-// Create a shared_ptr that put the connection back to the pool automatically
-WSConnectionPtr WSConnectionPool::MakeConnectionPtr(HostInfo& info, WSConnectionUPtr&& conn)
-{
- // A shared_ptr that keeps a reference to the original connection (pointer),
- // with a deleter that puts the connection back to the pool
- std::shared_ptr ptr(
- new WSConnectionUPtr(std::move(conn)),
- [&info](WSConnectionUPtr* pPtr)
- {
- PutBackConnection(info, std::move(*pPtr));
- delete pPtr;
- }
- );
-
- // Returns an aliasing pointer that points to the actual connection object
- return WSConnectionPtr(ptr, ptr->get());
-}
-
-void WSConnectionPool::CreateConnection(
- std::string url,
- const std::string& key,
- HostInfo& info)
-{
- ReplaceString(url, "{Sec-MS-GEC}", GetGECToken());
-
- std::error_code ec;
- auto conn = m_client.get_connection(url, ec);
- if (ec)
- throw std::system_error(ec);
-
- WSConnectionUPtr wrapper(new WSConnection(conn));
-
- SetConnectionHandlers(info, wrapper.get());
-
- conn->append_header("User-Agent", EDGE_USER_AGENT);
-
- if (!key.empty())
- conn->append_header("Ocp-Apim-Subscription-Key", key);
-
- std::string proxy = GetProxyForUrl(url);
- if (!proxy.empty())
- {
- size_t schemeDelimPos = proxy.find("://");
- if (schemeDelimPos == proxy.npos)
- {
- conn->set_proxy("http://" + std::move(proxy));
- }
- else
- {
- std::string_view scheme(proxy.data(), schemeDelimPos);
- if (EqualsIgnoreCase(scheme, "http"))
- conn->set_proxy(proxy);
- }
- }
-
- m_client.connect(conn);
- info.connections.push_back(std::move(wrapper));
- info.count++;
- LogDebug("Connection pool: New connection {} created and put into pool ({}/{})",
- conn, info.connections.size(), info.count);
-}
-
-void WSConnectionPool::SetConnectionHandlers(HostInfo& info, WSConnection* wrapper)
-{
- // Here we assume that the wrapper will live as long as the connection does.
- // When the wrapper is going to be destroyed, reset the connection's handlers
- // using RemoveConnection().
-
- auto& conn = wrapper->m_conn;
-
- conn->set_open_handler([&info](websocketpp::connection_hdl hdl)
- {
- std::lock_guard lock(info.mutex);
- info.lastException = nullptr;
- LogDebug("Connection pool: Connection {} opened", hdl);
- info.connectionChanged.notify_all();
- });
-
- conn->set_message_handler([wrapper](websocketpp::connection_hdl hdl, WSClient::message_ptr msg)
- {
- wrapper->on_message(hdl, msg);
- });
-
- conn->set_close_handler([&info, wrapper](websocketpp::connection_hdl hdl)
- {
- wrapper->on_close(hdl);
- std::lock_guard lock(info.mutex);
- info.lastException = nullptr;
- LogDebug("Connection pool: Connection {} closed, removed from pool ({}/{})",
- hdl, info.connections.size(), info.count);
- RemoveConnection(info, wrapper);
- info.connectionChanged.notify_all();
- });
-
- conn->set_fail_handler([&info, wrapper](websocketpp::connection_hdl hdl)
- {
- FILETIME ftNow;
- GetSystemTimeAsFileTime(&ftNow);
- LONGLONG llNow = ULARGE_INTEGER{ ftNow.dwLowDateTime, ftNow.dwHighDateTime }.QuadPart;
-
- std::lock_guard lock(info.mutex);
- auto ec = wrapper->get_ec();
- std::system_error ex(ec);
- info.lastException = ec ? std::make_exception_ptr(ex) : nullptr;
- LogDebug("Connection pool: Connection {} failed, removed from pool: {}", hdl, ex);
- if (ec == websocketpp::processor::error::invalid_http_status)
- {
- // The server rejected the connection. The local time may be incorrect.
- // Re-calculate the time delta so that the next attempt may succeed.
- LONGLONG llResponse = wrapper->get_response_filetime();
- if (llResponse != 0)
- s_responseTimeDelta.store(llResponse - llNow, std::memory_order_relaxed);
- }
- RemoveConnection(info, wrapper);
- info.connectionChanged.notify_all();
- });
-}
-
-void WSConnectionPool::RemoveConnection(HostInfo& info, WSConnection* wrapper)
-{
- // reset handlers to prevent them being called after free
- auto& conn = wrapper->m_conn;
- conn->set_open_handler(nullptr);
- conn->set_message_handler(nullptr);
- conn->set_close_handler(nullptr);
- conn->set_fail_handler(nullptr);
-
- // here the wrapper object and the connection are freed
- info.count -=
- info.connections.remove_if([wrapper](const WSConnectionUPtr& other) { return other.get() == wrapper; });
-}
-
-void WSConnectionPool::KeepConnectionsAlive()
-{
- // Edge voice connections can be closed after 30 seconds of inactivity.
- // Sending empty 'speech.config' can keep it alive longer,
- // but still no more than 4 minutes.
- // So here we check if any connection lives long enough,
- // if so, replace it with a new connection.
-
- const auto now = clock::now();
-
- std::lock_guard outer_lock(m_mutex);
- for (auto& host : m_hosts)
- {
- auto& info = host.second;
- std::lock_guard inner_lock(info.mutex);
-
- if (now - info.lastActiveTime > m_keepAliveDuration)
- continue; // no need to keep this host group alive
-
- std::vector connectionsToClose;
-
- for (auto it = info.connections.begin(); it != info.connections.end(); ++it)
- {
- auto& conn = *it;
- if (conn->get_state() != websocketpp::session::state::open) // already dead
- continue;
-
- // keep the connection alive
- conn->send(
- "Content-Type:application/json; charset=utf-8\r\n"
- "Path:speech.config\r\n\r\n"
- "{}", 70,
- websocketpp::frame::opcode::text);
-
- if (now - conn->get_creation_time() > m_maxConnectionAge)
- {
- // should be closed later
- connectionsToClose.push_back(it);
- }
- }
-
- // some of the connections can be closed now
- if (!connectionsToClose.empty() && info.count > m_minCount)
- {
- const size_t closeCount = std::min(info.count - m_minCount, connectionsToClose.size());
- if (closeCount < connectionsToClose.size())
- {
- // If we cannot close all of them, sort to get the oldest N connections we can close
- std::sort(connectionsToClose.begin(), connectionsToClose.end(),
- [](auto& a, auto& b) { return (*a)->get_creation_time() < (*b)->get_creation_time(); });
- }
- // close the oldest N connections
- for (size_t i = 0; i < closeCount; i++)
- {
- (*connectionsToClose[i])->close(websocketpp::close::status::normal, {});
- }
- }
-
- // Replenish new connections as if all connections to close had been closed.
- const size_t replenishToCount = std::min(
- std::max(info.count - connectionsToClose.size(), m_minCount) + connectionsToClose.size(),
- m_maxCount);
- for (size_t n = info.count; n < replenishToCount; n++)
- {
- CreateConnection(host.first.url, host.first.key, info);
- }
- }
-}
diff --git a/VoiceGardenSAPIAdapter/WSConnectionPool.h b/VoiceGardenSAPIAdapter/WSConnectionPool.h
deleted file mode 100644
index fc4d92b..0000000
--- a/VoiceGardenSAPIAdapter/WSConnectionPool.h
+++ /dev/null
@@ -1,227 +0,0 @@
-#pragma once
-#define ASIO_STANDALONE 1
-#include
-#include
-#include
-#include "WSLogger.h"
-#include "Logger.h"
-#include