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. 关闭安装程序窗口以应用更改。若之后想更改设置,可以再次打开安装程序。更改设置无需重新安装,无需管理员权限。 - -![中文安装程序界面](https://github.com/user-attachments/assets/22fe9b09-555f-4878-8a80-7ad3ae92fb60) - - -也可以用 `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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +