diff --git a/.github/workflows/msbuild.yml b/.github/workflows/msbuild.yml index 021b5e1..e95ac13 100644 --- a/.github/workflows/msbuild.yml +++ b/.github/workflows/msbuild.yml @@ -223,14 +223,18 @@ jobs: Where-Object { $_.Name -like 'Microsoft.CognitiveServices.Speech.*.dll' -or $_.Name -in 'SpeechSDKShim.dll','SpeechSDKPatcher.exe' } | Remove-Item -Force - # Rust TTS wrapper native DLL (from NuGet package) + # Rust TTS wrapper native DLL (from NuGet package). + # Pinned to the exact RustTtsWrapper.Bindings version referenced by + # the csproj — grabbing "whatever is in the cache" once shipped a DLL + # with the wrong callback ABI (issue #15). Hard-fail if the pinned + # package has no native DLL: the adapter cannot run without it. 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" + foreach ($rid in @('win-x64', 'win-x86')) { + $rustDll = & .\scripts\Get-RustTtsWrapperDll.ps1 -Rid $rid + if (-not $rustDll) { throw "rust_tts_wrapper.dll missing for $rid (see warnings above)" } + $arch = $rid -replace 'win-', '' + Copy-Item $rustDll "payload\$arch\" -Force + Write-Host "Copied rust_tts_wrapper.dll ($rid, csproj-pinned) to payload\$arch\" } - name: Build MSI diff --git a/README.md b/README.md index be132df..4306462 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 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 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`. +A [SAPI 5 text-to-speech engine][1] that connects **23 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. @@ -10,12 +10,14 @@ Powered by [rust-tts-wrapper](https://github.com/AACTools/rust-tts-wrapper) for | Category | Engines | Cloud? | |----------|---------|--------| -| **Offline neural** | SherpaOnnx (Kokoro, Piper, MMS, VITS, Matcha, Kitten) | No — fully local | +| **Offline neural** | floravox (piper/MMS VITS, Matcha, Kokoro — measured word timings, SSML bookmarks, lexicon + Phonetisaurus + ByT5 G2P) and SherpaOnnx (Kokoro, Piper, MMS, VITS, Matcha, Kitten) as fallback | 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 | -All engines support **word boundary events** for word highlighting in AAC software. +All engines support **word boundary events** for word highlighting in AAC software. Offline voices route to **floravox** wherever it's supported (piper/MMS VITS, Matcha, Kokoro — self-served from sherpa layout; duration-tensor timings on patched voices, SSML `` → `SPEI_TTSBOOKMARK`, published per-language G2P bundles cached locally, synthesis still works offline). **SherpaOnnx** is the automatic fallback (32-bit hosts, engine failure) and the only engine for the flow/diffusion families (zipvoice, supertonic, pocket, kitten). + +\* floravox on x64 needs Windows 10 1903+ (the static onnxruntime DirectML floor); older systems fall back to SherpaOnnx. ## Quick Start @@ -56,19 +58,21 @@ VoiceGarden.UI.exe validate --engine azure --voice en-US-JennyNeural --key KEY - │ │ VoiceGardenSAPIAdapter.dll (C++ COM DLL) │ │ │ │ • BuildSSML (SAPI fragments → SSML) │ │ │ │ • Word boundary offset mapping │ │ +│ │ • SSML marks → SPEI_TTSBOOKMARK (floravox) │ │ │ │ • 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 │ │ │ -│ │ └──────────────────────────────────────────────┘ │ │ +│ │ ┌────────▼─────────────────────────────────┐ │ │ +│ │ │ rust_tts_wrapper.dll (Rust) │ │ │ +│ │ │ • 23 engines (floravox, SherpaOnnx, │ │ │ +│ │ │ Azure, Edge, OpenAI, Google, ...) │ │ │ +│ │ │ • Word boundaries (Azure/Google: real, │ │ │ +│ │ │ floravox: measured, others: estimated) │ │ │ +│ │ │ • Viseme events (Azure/Edge) │ │ │ +│ │ │ • Connection pooling (Azure/Edge WS) │ │ │ +│ │ │ • Sec-MS-GEC token (Edge voices) │ │ │ +│ │ │ • SherpaOnnx model auto-detection │ │ │ +│ │ │ • ABI canary: refuses pre-0.5 DLLs │ │ │ +│ │ └───────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ @@ -87,8 +91,10 @@ VoiceGarden.UI.exe validate --engine azure --voice en-US-JennyNeural --key KEY - | Component | Description | |-----------|-------------| | `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) | +| `VoiceGardenSAPIAdapter/RustTts/` | C++ wrapper for the Rust DLL (dynamic loading, ABI canary, callback marshalling) | | `VoiceGarden.UI/` | Avalonia UI app — configuration, model management, voice preview, analytics | +| `VoiceGarden.UI/Services/PiperSidecarGenerator.cs` | Generates piper `*.onnx.json` sidecars for sherpa-layout voices so they route through floravox | +| `VoiceGarden.UI.Tests/` | xunit tests: floravox integration (boundaries, marks, G2P) + sidecar generator units | | `SherpaOnnx/` | Model discovery (voice enumerator scans for installed models) | | `Setup/` + `SetupLauncher/` | WiX MSI package + setup.exe bootstrapper | @@ -128,6 +134,13 @@ Each cloud engine needs its API key set in the Engine Config tab. Search voices ## Testing +### Unit + integration (.NET) +```powershell +dotnet test VoiceGarden.UI.Tests\VoiceGarden.UI.Tests.csproj # floravox integration (skips w/o local models) + sidecar generator units +.\scripts\test-rust-dll-pin.ps1 # CI ships the csproj-pinned rust DLL +.\scripts\test-rust-abi.ps1 # export/ABI canary check incl. negative control +``` + ### Boundary crash test (Grid3 pattern) ```powershell .\scripts\test-boundary-crash.ps1 # PromptBuilder with rate changes — reproduces Grid3 crash diff --git a/VoiceGarden.UI.Tests/FloravoxEngineTests.cs b/VoiceGarden.UI.Tests/FloravoxEngineTests.cs new file mode 100644 index 0000000..cc9e42c --- /dev/null +++ b/VoiceGarden.UI.Tests/FloravoxEngineTests.cs @@ -0,0 +1,172 @@ +// Integration tests for the floravox engine through the shipped NuGet DLL +// (issue #15 stage 4). These exercise the exact native binary the SAPI +// adapter loads: RustTtsWrapper.Bindings 0.5.3 with sherpaonnx + +// floravox-lexicons on win-x64. +// +// Boundary/mark timings require a real piper voice. They are skipped when +// no voice is found under %LOCALAPPDATA%\VoiceGardenSAPIAdapter\models so CI +// without models still runs the offline checks. + +using RustTtsWrapper; +using Xunit; + +namespace VoiceGarden.UI.Tests; + +public sealed class FloravoxEngineTests : IDisposable +{ + private readonly string? _voiceDir; + private readonly string? _kokoroDir; + + public FloravoxEngineTests() + { + var modelsRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "VoiceGardenSAPIAdapter", "models"); + if (!Directory.Exists(modelsRoot)) + { + return; + } + // First piper voice dir with a *.onnx.json sidecar (routing key used + // by the C++ adapter — see ModelSupportsFloravox in TTSEngine.cpp). + _voiceDir = (from d in Directory.EnumerateDirectories(modelsRoot, "*", SearchOption.AllDirectories) + where Directory.EnumerateFiles(d, "*.onnx.json").Any() + let onnx = Directory.EnumerateFiles(d, "*.onnx").FirstOrDefault() + where onnx != null + select d).FirstOrDefault(); + // Kokoro voice in pure sherpa layout (model.onnx + tokens.txt + + // voices.bin, NO sidecar) — floravox self-serves these. + _kokoroDir = (from d in Directory.EnumerateDirectories(modelsRoot, "*", SearchOption.TopDirectoryOnly) + where Path.GetFileName(d).StartsWith("kokoro-en-", StringComparison.OrdinalIgnoreCase) + where Directory.EnumerateFiles(d, "voices.bin", SearchOption.AllDirectories).Any() + where Directory.EnumerateFiles(d, "tokens.txt", SearchOption.AllDirectories).Any() + where Directory.EnumerateFiles(d, "*.onnx", SearchOption.AllDirectories) + .Any(f => !Path.GetFileName(f).Contains("vocoder")) + select Directory.EnumerateDirectories(d).FirstOrDefault( + inner => Directory.EnumerateFiles(inner, "model.onnx").Any())) + .FirstOrDefault(); + } + + public void Dispose() { } + + private string? ModelsRoot => _voiceDir is null ? null + : Path.GetFullPath(Path.Combine(_voiceDir, "..", "..")); + + private Dictionary Creds(string modelId, string modelsRoot, bool withLang) => + withLang + ? new() { ["modelId"] = modelId, ["modelsDir"] = modelsRoot, ["lang"] = "en" } + : new() { ["modelId"] = modelId, ["modelsDir"] = modelsRoot }; + + [Fact] + public void KokoroSpeaksThroughFloravoxFromSherpaLayout() + { + // Kokoro ships as model.onnx + tokens.txt + voices.bin with NO + // piper sidecar — floravox's KokoroBackend self-serves the layout. + // This is the path the C++ adapter now routes kokoro voices through. + if (_kokoroDir is null) + { + return; // no local kokoro voice — skip + } + + var modelsRoot = ModelsRoot!.Replace('\\', '/'); + var modelId = _kokoroDir!.Replace('\\', '/')[(modelsRoot.Length + 1)..]; + var creds = new Dictionary + { + ["modelId"] = modelId, + ["modelsDir"] = modelsRoot, + ["lang"] = "en", + ["misaki"] = "us", + }; + + using var client = new TtsClient("floravox", creds); + var audioBytes = 0L; + var boundaries = new List(); + client.SetOnAudio(data => audioBytes += data.Length); + client.SetOnBoundary((w, _, _, s, e, _) => boundaries.Add(w)); + + client.SpeakSync("Kokoro speaks through floravox with the number 42."); + + Assert.True(audioBytes > 0, $"no audio produced ({audioBytes} bytes)"); + Assert.NotEmpty(boundaries); + } + + [Fact] + public void MissingVoiceSurfacesAsSpeakError() + { + // Engine construction is lazy (the model is resolved on first + // synthesis), so a nonexistent modelId must surface as a speak-time + // exception — never a silent success or a crash. The C++ adapter's + // sherpaonnx fallback keys off construction failures (engine absent + // from the DLL), which this complements. + using var c = new TtsClient("floravox", Creds("no-such-voice", ".", withLang: false)); + Assert.ThrowsAny(() => c.SpeakSync("hello")); + } + + [Fact] + public void FloravoxSpeaksSsmlWithBoundariesAndMarks() + { + if (_voiceDir is null) + { + return; // no local voice — skip + } + + var modelsRoot = ModelsRoot!.Replace('\\', '/'); + var modelId = _voiceDir!.Replace('\\', '/')[(modelsRoot.Length + 1)..]; + + using var client = new TtsClient("floravox", Creds(modelId, modelsRoot, withLang: true)); + + var audioBytes = 0L; + var boundaries = new List<(string word, float start, float end, bool estimated)>(); + var marks = new List<(string name, float start)>(); + + client.SetOnAudio(data => audioBytes += data.Length); + client.SetOnBoundary((word, _, _, start, end, estimated) => + boundaries.Add((word, start, end, estimated))); + client.SetOnMark((name, _, start, _) => marks.Add((name, start))); + + client.SpeakSync("Hello world, floravox measures this."); + + Assert.True(audioBytes > 0, $"no audio produced ({audioBytes} bytes)"); + Assert.NotEmpty(boundaries); + + // Timings are monotonic and land inside the audio (they are scaled to + // the synthesized length even for unpatched voices; voices patched + // with floravox's duration-graph surgery additionally report + // estimated == false). + var starts = boundaries.Select(b => b.start).ToList(); + Assert.Equal(starts.OrderBy(s => s), starts); + Assert.All(boundaries, b => Assert.True(b.start >= 0 && b.end > b.start)); + + // The bookmark must fire as a mark event (mapped to SPEI_TTSBOOKMARK + // by the C++ adapter). + Assert.Contains(marks, m => m.name == "vg1"); + } + + [Fact] + public void LexiconG2pHandlesOovWord() + { + // "floravox" is not in a gruut lexicon; the OOV chain (lexicon → + // Phonetisaurus → ByT5 → spell) must still produce audio without + // failing. Needs the lang bundle, which may require network on first + // run — treated as skip if synthesis fails offline. + if (_voiceDir is null) + { + return; + } + + var modelsRoot = ModelsRoot!.Replace('\\', '/'); + var modelId = _voiceDir!.Replace('\\', '/')[(modelsRoot.Length + 1)..]; + + using var client = new TtsClient("floravox", Creds(modelId, modelsRoot, withLang: true)); + var audioBytes = 0L; + client.SetOnAudio(data => audioBytes += data.Length); + try + { + client.SpeakSync("The floravox vocalizes."); + Assert.True(audioBytes > 0, $"no audio for OOV sentence ({audioBytes} bytes)"); + } + catch (Exception ex) when (ex.Message.Contains("lexicon", StringComparison.OrdinalIgnoreCase)) + { + // First-run bundle fetch offline — acceptable skip. + } + } +} diff --git a/VoiceGarden.UI.Tests/PiperSidecarGeneratorTests.cs b/VoiceGarden.UI.Tests/PiperSidecarGeneratorTests.cs new file mode 100644 index 0000000..3555969 --- /dev/null +++ b/VoiceGarden.UI.Tests/PiperSidecarGeneratorTests.cs @@ -0,0 +1,208 @@ +// Unit tests for the piper sidecar generator (issue #15 stage 5, SPD +// installer-parity port): generation from sherpa layout, stale-sidecar +// re-run, phoneme_id_map casefold, and language_code derivation. + +using System.Text.Json; +using VoiceGarden.UI.Services; +using Xunit; + +namespace VoiceGarden.UI.Tests; + +public sealed class PiperSidecarGeneratorTests : IDisposable +{ + private readonly string _dir; + + public PiperSidecarGeneratorTests() + { + _dir = Path.Combine(Path.GetTempPath(), "vg-sidecar-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private void WriteSherpaLayout(string tokens) + { + File.WriteAllText(Path.Combine(_dir, "model.onnx"), "fake-onnx"); + File.WriteAllText(Path.Combine(_dir, "tokens.txt"), tokens); + } + + private JsonDocument ReadSidecar(string path) + { + Assert.True(File.Exists(path), $"sidecar not written: {path}"); + return JsonDocument.Parse(File.ReadAllText(path)); + } + + [Fact] + public void GeneratesSidecarForSherpaLayout() + { + WriteSherpaLayout("a 1\nb 2 3\n"); + + var written = PiperSidecarGenerator.EnsureSidecar(_dir, "mms_eng", sampleRate: 16000); + + Assert.NotNull(written); + using var doc = ReadSidecar(written!); + var root = doc.RootElement; + + Assert.Equal(16000, root.GetProperty("audio").GetProperty("sample_rate").GetInt32()); + Assert.Equal(1, root.GetProperty("num_speakers").GetInt32()); + + var map = root.GetProperty("phoneme_id_map"); + Assert.Equal(new long[] { 1 }, map.GetProperty("a").EnumerateArray().Select(e => e.GetInt64()).ToArray()); + Assert.Equal(new long[] { 2, 3 }, map.GetProperty("b").EnumerateArray().Select(e => e.GetInt64()).ToArray()); + } + + [Fact] + public void CasefoldsPhonemeMapKeys() + { + // sherpa token tables ship single-case symbols; the G2P chain looks + // up phonemes case-insensitively (SPD parity fix). + WriteSherpaLayout("ɑ 1\nˈ 5\n"); + + var written = PiperSidecarGenerator.EnsureSidecar(_dir, "mms_eng", sampleRate: null); + + using var doc = ReadSidecar(written!); + var map = doc.RootElement.GetProperty("phoneme_id_map"); + Assert.True(map.TryGetProperty("ɑ", out _)); + // Folded duplicates are NOT invented for symbols that differ only in + // case from an existing key, but invariant folding of a symbol with + // no case ("ɑ" folds to itself) must not corrupt the map. + Assert.Equal(1, map.GetProperty("ɑ").EnumerateArray().First().GetInt64()); + } + + [Fact] + public void CasefoldAddsMissingVariant() + { + // "AA" exists, "aa" does not → folded variant is added with same ids. + WriteSherpaLayout("AA 7\n"); + + var written = PiperSidecarGenerator.EnsureSidecar(_dir, "mms_eng", sampleRate: null); + + using var doc = ReadSidecar(written!); + var map = doc.RootElement.GetProperty("phoneme_id_map"); + Assert.True(map.TryGetProperty("aa", out var folded)); + Assert.Equal(7, folded.EnumerateArray().First().GetInt64()); + Assert.True(map.TryGetProperty("AA", out var original)); + Assert.Equal(7, original.EnumerateArray().First().GetInt64()); + } + + [Theory] + [InlineData("piper-en_US-amy-low", "en-US")] + [InlineData("piper-fa_IR-amir-medium", "fa-IR")] + [InlineData("mms_eng", "en")] + [InlineData("mms_fas", "fa")] + [InlineData("coqui-en-ljspeech", "")] // unknown family — no language + [InlineData("kokoro-en-v0_19", "")] // kokoro → no sidecar language + public void DerivesLanguageCode(string modelId, string expected) + { + Assert.Equal(expected, PiperSidecarGenerator.DeriveLanguageCode(modelId)); + } + + [Fact] + public void LanguageCodeLandsInSidecar() + { + WriteSherpaLayout("a 1\n"); + + var written = PiperSidecarGenerator.EnsureSidecar(_dir, "piper-fa_IR-amir-medium", sampleRate: null); + + using var doc = ReadSidecar(written!); + Assert.Equal("fa-IR", doc.RootElement.GetProperty("language").GetProperty("code").GetString()); + } + + [Fact] + public void SkipsKokoroVoices() + { + WriteSherpaLayout("a 1\n"); + File.WriteAllText(Path.Combine(_dir, "voices.bin"), "fake"); + + var written = PiperSidecarGenerator.EnsureSidecar(_dir, "kokoro-en-v0_19", sampleRate: null); + + Assert.Null(written); + } + + [Theory] + [InlineData("zipvoice-zh_en-emilia")] + [InlineData("supertonic-3-multilingual")] + [InlineData("kyutai-en-pocket-tts")] // "pocket" is not a prefix here — matches anywhere + [InlineData("kitten")] + public void FlowFamiliesNeverGetSidecars(string modelId) + { + // These carry tokens.txt like every sherpa model but are flow / + // diffusion graphs floravox cannot load — the sidecar would only + // invite a broken floravox route. They stay on sherpa-onnx. + WriteSherpaLayout("a 1\n"); + + Assert.Null(PiperSidecarGenerator.EnsureSidecar(_dir, modelId, sampleRate: null)); + Assert.False(File.Exists(Path.Combine(_dir, "model.onnx.json"))); + } + + [Fact] + public void RegeneratesOurStaleSidecar_NeverShippedOnes() + { + WriteSherpaLayout("a 1\n"); + var sidecar = Path.Combine(_dir, "model.onnx.json"); + + // First run writes it (marked as ours). + Assert.NotNull(PiperSidecarGenerator.EnsureSidecar(_dir, "mms_eng", sampleRate: 16000)); + + // Fresh: no rewrite. + File.SetLastWriteTimeUtc(sidecar, DateTime.UtcNow.AddMinutes(1)); + Assert.Null(PiperSidecarGenerator.EnsureSidecar(_dir, "mms_eng", sampleRate: 16000)); + + // Model updated in place (tokens.txt newer) → our sidecar is + // re-generated with the new map (SPD "generate_sidecar() re-run" rule). + File.WriteAllText(Path.Combine(_dir, "tokens.txt"), "a 1\nz 9\n"); + File.SetLastWriteTimeUtc(Path.Combine(_dir, "tokens.txt"), DateTime.UtcNow.AddMinutes(2)); + var rewritten = PiperSidecarGenerator.EnsureSidecar(_dir, "mms_eng", sampleRate: 16000); + Assert.NotNull(rewritten); + using var doc = ReadSidecar(rewritten!); + Assert.True(doc.RootElement.GetProperty("phoneme_id_map").TryGetProperty("z", out _)); + + // A SHIPPED sidecar (piper release / floravox-patched, no marker) is + // never overwritten — regenerating would drop its espeak/inference + // config even when tokens.txt is newer. + File.Delete(sidecar); + File.WriteAllText(sidecar, + """{ "espeak": { "voice": "en-us" }, "inference": { "noise_scale": 0.667 } }"""); + File.SetLastWriteTimeUtc(sidecar, DateTime.UtcNow.AddMinutes(-10)); + File.SetLastWriteTimeUtc(Path.Combine(_dir, "tokens.txt"), DateTime.UtcNow); + Assert.Null(PiperSidecarGenerator.EnsureSidecar(_dir, "mms_eng", sampleRate: 16000)); + Assert.Contains("noise_scale", File.ReadAllText(sidecar)); + } + + [Fact] + public void NoSidecarWithoutTokensFile() + { + File.WriteAllText(Path.Combine(_dir, "model.onnx"), "fake-onnx"); + + Assert.Null(PiperSidecarGenerator.EnsureSidecar(_dir, "mms_eng", sampleRate: null)); + } + + [Fact] + public void TokensParserSkipsCountHeaderAndMergesIds() + { + var tokens = Path.Combine(_dir, "tokens.txt"); + File.WriteAllText(tokens, "3\na 1\nb 2\na 3\n"); + + var map = PiperSidecarGenerator.ParseTokensFile(tokens); + + Assert.False(map.ContainsKey("3")); // header skipped + Assert.Equal(new[] { 1L, 3L }, map["a"]); + Assert.Equal(new[] { 2L }, map["b"]); + } + + [Fact] + public void ReadsSampleRateFromMmsConfigJson() + { + WriteSherpaLayout("a 1\n"); + File.WriteAllText(Path.Combine(_dir, "config.json"), + """{ "data": { "sampling_rate": 16000, "hop_length": 256 } }"""); + + var written = PiperSidecarGenerator.EnsureSidecar(_dir, "mms_eng", sampleRate: null); + + using var doc = ReadSidecar(written!); + Assert.Equal(16000, doc.RootElement.GetProperty("audio").GetProperty("sample_rate").GetInt32()); + } +} diff --git a/VoiceGarden.UI.Tests/VoiceGarden.UI.Tests.csproj b/VoiceGarden.UI.Tests/VoiceGarden.UI.Tests.csproj new file mode 100644 index 0000000..cb944b7 --- /dev/null +++ b/VoiceGarden.UI.Tests/VoiceGarden.UI.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + latest + false + + CA1416 + + + + + + + + + + + + + + + diff --git a/VoiceGarden.UI/Services/PiperSidecarGenerator.cs b/VoiceGarden.UI/Services/PiperSidecarGenerator.cs new file mode 100644 index 0000000..d45c6da --- /dev/null +++ b/VoiceGarden.UI/Services/PiperSidecarGenerator.cs @@ -0,0 +1,283 @@ +using System.Text; +using System.Text.Json; + +namespace VoiceGarden.UI.Services; + +/// +/// Generates piper-style sidecar configs (X.onnx.json) for local +/// voices that ship in sherpa layout (model.onnx + tokens.txt) +/// so the floravox engine can load them (issue #15, SPD installer parity). +/// +/// Ports the SPD hardening rules: +/// - sidecar is re-generated when tokens.txt is newer (models updated +/// in place must never keep a stale phoneme map), +/// - phoneme_id_map keys are case-folded (the G2P chain looks up +/// phonemes case-insensitively; sherpa token tables ship single-case), +/// - a language.code sidecar is written when derivable from the +/// model id (floravox 0.8.5 uses it for language routing). +/// +public static class PiperSidecarGenerator +{ + /// + /// Ensure a sidecar exists (and is fresh) next to the model in + /// . Returns the sidecar path when one was + /// written, null when nothing needed doing (or the layout is not + /// supported). Never throws. + /// + /// Directory holding the .onnx + tokens.txt. + /// Canonical model id (e.g. "mms_eng", "piper-en_US-amy-low"). + /// Sample rate when known (catalog); otherwise read from a sibling config.json, else omitted. + public static string? EnsureSidecar(string modelDir, string modelId, int? sampleRate = null) + { + try + { + // Flow/diffusion families (zipvoice, supertonic, pocket, kitten) + // are not floravox graphs - they carry tokens.txt too, so they + // must be excluded by name before any layout check. + if (IsFlowModelFamily(modelId)) + return null; + + var onnx = FindModelOnnx(modelDir); + if (onnx is null) return null; + + var tokensPath = Path.Combine(modelDir, "tokens.txt"); + if (!File.Exists(tokensPath)) return null; + + // Kokoro voices don't need a generated sidecar: floravox's + // KokoroBackend reads tokens.txt and voices.bin directly from + // the model dir. + if (File.Exists(Path.Combine(modelDir, "voices.bin"))) return null; + + var sidecarPath = onnx + ".json"; + if (File.Exists(sidecarPath)) + { + // Only OUR generated sidecars are refreshed. A shipped or + // patched sidecar (piper releases, floravox duration-surgery + // output) is authoritative — regenerating it from tokens.txt + // would drop espeak/inference/audio config it carries. + if (!WasGeneratedByUs(sidecarPath)) + return null; + + var sidecarTime = File.GetLastWriteTimeUtc(sidecarPath); + var tokensTime = File.GetLastWriteTimeUtc(tokensPath); + if (sidecarTime >= tokensTime) + return null; // fresh — nothing to do + } + + var map = ParseTokensFile(tokensPath); + if (map.Count == 0) return null; + + // Case-fold keys: add the invariant-folded variant of every + // symbol when it is not already present (SPD parity — G2P looks + // up phonemes case-insensitively). + var folded = new Dictionary>(map.Count); + foreach (var (sym, ids) in map) + { + folded[sym] = ids; + var lower = sym.ToLowerInvariant(); + if (lower != sym && !map.ContainsKey(lower)) + folded.TryAdd(lower, ids); + } + + sampleRate ??= ReadSampleRateFromConfigJson(modelDir); + + var language = DeriveLanguageCode(modelId); + + var json = BuildSidecarJson(folded, sampleRate, language); + WriteAtomically(sidecarPath, json); + return sidecarPath; + } + catch + { + // Sidecar generation is best-effort: the sherpa-onnx engine + // still works without it. + return null; + } + } + + /// Marker key written into generated sidecars (never shipped ones). + private const string GeneratorMarker = "voicegardenGenerated"; + + /// + /// Flow/diffusion model families that floravox cannot load (its backends + /// are piper/MMS VITS, Matcha + vocoder, Kokoro). They carry tokens.txt + /// like every sherpa model, so exclusion is by name. + /// + internal static bool IsFlowModelFamily(string modelId) + { + var id = modelId.ToLowerInvariant(); + return id.Contains("zipvoice") || id.Contains("supertonic") + || id.Contains("pocket") || id.Contains("kitten"); + } + + private static bool WasGeneratedByUs(string sidecarPath) + { + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(sidecarPath)); + return doc.RootElement.TryGetProperty(GeneratorMarker, out _); + } + catch + { + return false; + } + } + + /// The main acoustic model (prefers model.onnx, skips vocoders). + private static string? FindModelOnnx(string modelDir) + { + if (!Directory.Exists(modelDir)) return null; + var candidates = Directory.GetFiles(modelDir, "*.onnx"); + return candidates.FirstOrDefault(f => + string.Equals(Path.GetFileName(f), "model.onnx", StringComparison.OrdinalIgnoreCase)) + ?? candidates.FirstOrDefault(f => + !Path.GetFileName(f).Contains("vocoder", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// sherpa tokens.txt: one symbol per line followed by one or more ids + /// ("symbol id", "symbol id1 id2"). The first line may be a + /// count header ("32") — entries whose "symbol" is all digits and + /// which have no ids are skipped. + /// + internal static Dictionary> ParseTokensFile(string tokensPath) + { + var map = new Dictionary>(); + foreach (var rawLine in File.ReadAllLines(tokensPath)) + { + var line = rawLine.Trim(); + if (line.Length == 0) continue; + + var space = line.IndexOf(' '); + if (space <= 0) continue; // count header or malformed + + var symbol = line[..space]; + var rest = line[(space + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (rest.Length == 0) continue; + if (symbol.All(char.IsAsciiDigit)) continue; // header line + + var ids = new List(rest.Length); + var ok = true; + foreach (var t in rest) + { + if (long.TryParse(t, out var id)) ids.Add(id); + else { ok = false; break; } + } + if (!ok) continue; + + if (map.TryGetValue(symbol, out var existing)) existing.AddRange(ids.Where(id => !existing.Contains(id))); + else map[symbol] = ids; + } + return map; + } + + /// MMS training configs carry data.sampling_rate. + private static int? ReadSampleRateFromConfigJson(string modelDir) + { + var configPath = Path.Combine(modelDir, "config.json"); + if (!File.Exists(configPath)) return null; + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(configPath)); + if (doc.RootElement.TryGetProperty("data", out var data) && + data.TryGetProperty("sampling_rate", out var rate) && + rate.TryGetInt32(out var sr)) + return sr; + } + catch { /* best effort */ } + return null; + } + + /// + /// Derives a BCP-47-ish language code from the model id: + /// piper-en_US-amy-low → en-US, mms_eng → en. Empty when unknown. + /// + internal static string DeriveLanguageCode(string modelId) + { + // Piper ids are dash-separated with an underscore locale + // ("piper-en_US-amy-low" → en-US); MMS ids are underscore-separated + // ("mms_eng" → en). + // Family = everything before the first '-' or '_' ("piper-en_US-…" + // → piper, "mms_eng" → mms). + var sep = modelId.IndexOfAny(new[] { '-', '_' }); + var family = (sep < 0 ? modelId : modelId[..sep]).ToLowerInvariant(); + + if (family == "piper") + { + var locale = modelId.Split('-', 3); // [piper, en_US, name…] + if (locale.Length > 1 && locale[1].Length > 0) + return locale[1].Replace('_', '-'); + } + else if (family == "mms") + { + var mms = modelId.Split('_'); // [mms, eng] + if (mms.Length > 1) + { + return mms[1].ToLowerInvariant() switch + { + "eng" => "en", + "fas" or "pes" => "fa", + "arb" => "ar", + "spa" => "es", + "fra" => "fr", + "deu" => "de", + "por" => "pt", + "ita" => "it", + "rus" => "ru", + "zho" => "zh", + "hin" => "hi", + _ => "", + }; + } + } + return ""; + } + + private static string BuildSidecarJson(Dictionary> phonemeMap, int? sampleRate, string language) + { + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + + // Marker so future runs can tell our minimal sidecars from + // shipped/patched ones (only ours are ever refreshed). + writer.WriteBoolean(GeneratorMarker, true); + + if (sampleRate.HasValue || language.Length > 0) + { + writer.WriteStartObject("audio"); + if (sampleRate.HasValue) writer.WriteNumber("sample_rate", sampleRate.Value); + writer.WriteEndObject(); + + if (language.Length > 0) + { + writer.WriteStartObject("language"); + writer.WriteString("code", language); + writer.WriteEndObject(); + } + } + + writer.WriteNumber("num_speakers", 1); + + writer.WriteStartObject("phoneme_id_map"); + foreach (var (symbol, ids) in phonemeMap.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + { + writer.WriteStartArray(symbol); + foreach (var id in ids) writer.WriteNumberValue(id); + writer.WriteEndArray(); + } + writer.WriteEndObject(); + + writer.WriteEndObject(); + } + return Encoding.UTF8.GetString(buffer.ToArray()); + } + + private static void WriteAtomically(string path, string content) + { + var tmp = path + ".tmp"; + File.WriteAllText(tmp, content); + File.Move(tmp, path, overwrite: true); + } +} diff --git a/VoiceGarden.UI/Services/SherpaModelService.cs b/VoiceGarden.UI/Services/SherpaModelService.cs index 6427369..6d6f372 100644 --- a/VoiceGarden.UI/Services/SherpaModelService.cs +++ b/VoiceGarden.UI/Services/SherpaModelService.cs @@ -553,6 +553,11 @@ public static List ScanInstalledModels() installed.ModelType = 1; // Matcha else installed.ModelType = 0; // VITS + + // Best-effort: give sherpa-layout voices a piper sidecar so + // the floravox engine (measured timing, lexicon G2P) can load + // them. Re-runs when tokens.txt is newer than the sidecar. + PiperSidecarGenerator.EnsureSidecar(modelDir, modelId); } result.Add(installed); @@ -776,9 +781,32 @@ public static async Task DownloadModelAsync(CatalogModel model, IProgress<(int p // shared models dir so synthesis works out of the box. await EnsureZipvoiceVocoderAsync(model, progress); + // Generate the piper sidecar while the catalog entry (with the + // sample rate) is at hand, so the floravox engine can pick the voice + // up on the next scan/promotion without needing sherpa layout. + // Flow families (zipvoice/supertonic/pocket/kitten) stay sherpa-only. + var extractedModelDir = FindExtractedModelDir(destDir); + if (extractedModelDir != null && !IsFlowModelType(model.ModelType)) + PiperSidecarGenerator.EnsureSidecar(extractedModelDir, model.Id, model.SampleRate); + progress?.Report((100, "Done")); } + /// Catalog model_type values floravox cannot load (flow/diffusion graphs). + private static bool IsFlowModelType(string? modelType) => modelType is not null + && (modelType.Equals("zipvoice", StringComparison.OrdinalIgnoreCase) + || modelType.Equals("supertonic", StringComparison.OrdinalIgnoreCase) + || modelType.Equals("pocket", StringComparison.OrdinalIgnoreCase) + || modelType.Equals("kitten", StringComparison.OrdinalIgnoreCase)); + + /// The .onnx-bearing directory inside a freshly extracted model dir (flat or nested). + private static string? FindExtractedModelDir(string modelDir) + { + var onnx = Directory.GetFiles(modelDir, "*.onnx", SearchOption.AllDirectories) + .FirstOrDefault(f => !Path.GetFileName(f).Contains("vocoder", StringComparison.OrdinalIgnoreCase)); + return onnx is null ? null : Path.GetDirectoryName(onnx); + } + /// /// Zipvoice models need the vocos_24khz.onnx vocoder, which lives in a /// separate sherpa-onnx release and is resolved from the models base dir diff --git a/VoiceGarden.UI/VoiceGarden.UI.csproj b/VoiceGarden.UI/VoiceGarden.UI.csproj index 92324fe..d71d26a 100644 --- a/VoiceGarden.UI/VoiceGarden.UI.csproj +++ b/VoiceGarden.UI/VoiceGarden.UI.csproj @@ -20,7 +20,7 @@ - + diff --git a/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.cpp b/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.cpp index 6edd65b..870ba6f 100644 --- a/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.cpp +++ b/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.cpp @@ -54,13 +54,8 @@ bool Engine::Speak(const std::string& text) { bool Engine::SpeakSsml(const std::string& ssml) { if (!m_ctx) return false; - // Debug: write marker file to verify SpeakSsml is called - FILE* f = nullptr; - fopen_s(&f, "C:\\Users\\WillWade\\AppData\\Local\\Temp\\vg_ssml_debug.txt", "a"); - if (f) { fprintf(f, "SpeakSsml len=%zu first80=%.80s\n", ssml.size(), ssml.c_str()); fclose(f); } auto& loader = Loader::Instance(); int32_t rc = loader.speakSsml(m_ctx, ssml.c_str()); - if (f) { fopen_s(&f, "C:\\Users\\WillWade\\AppData\\Local\\Temp\\vg_ssml_debug.txt", "a"); if (f) { fprintf(f, "rc=%d\n", rc); fclose(f); } } if (rc != 0) { const char* err = loader.getLastError(m_ctx); spdlog::warn("RustTts::Engine::SpeakSsml failed: {}", err ? err : "(unknown)"); @@ -102,6 +97,10 @@ void Engine::SetOnBoundary(BoundaryCallback cb) { m_onBoundary = std::move(cb); } +void Engine::SetOnMark(MarkCallback cb) { + m_onMark = std::move(cb); +} + void Engine::SetOnViseme(VisemeCallback cb) { m_onViseme = std::move(cb); } @@ -120,6 +119,9 @@ void Engine::RegisterCallbacks() { auto& loader = Loader::Instance(); loader.setOnAudio(m_ctx, &Engine::OnAudioThunk, this); loader.setOnBoundary(m_ctx, &Engine::OnBoundaryThunk, this); + if (loader.setOnMark) { + loader.setOnMark(m_ctx, &Engine::OnMarkThunk, this); + } loader.setOnViseme(m_ctx, &Engine::OnVisemeThunk, this); loader.setOnError(m_ctx, &Engine::OnErrorThunk, this); } @@ -144,6 +146,14 @@ void Engine::OnBoundaryThunk(const char* word, int32_t charOffset, } } +void Engine::OnMarkThunk(const char* name, int32_t charOffset, + float startS, float endS, void* ud) { + auto* self = static_cast(ud); + if (self && self->m_onMark) { + self->m_onMark(name, charOffset, startS, endS); + } +} + void Engine::OnVisemeThunk(int32_t visemeId, float offsetS, void* ud) { auto* self = static_cast(ud); if (self && self->m_onViseme) { diff --git a/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.h b/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.h index d58a679..b8053b8 100644 --- a/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.h +++ b/VoiceGardenSAPIAdapter/RustTts/RustTtsEngine.h @@ -14,9 +14,16 @@ namespace RustTts { // Audio chunk callback: (pcmBytes, numBytes) using AudioCallback = std::function; -// Boundary callback: (word, charOffset, charLen, startSec, endSec) +// Boundary callback: (word, charOffset, charLen, startSec, endSec, estimated). +// `estimated` is false when the engine measured the timing (floravox +// duration tensor, cloud provider timings) and true for synthetic +// interpolations (sherpa-onnx). using BoundaryCallback = std::function; +// Mark callback: (name, charOffset, startSec, endSec) — fired for SSML +// bookmarks (floravox; mapped to SPEI_TTSBOOKMARK). +using MarkCallback = std::function; + // Viseme callback: (visemeId, offsetSec) using VisemeCallback = std::function; @@ -60,6 +67,7 @@ class Engine { // Register callbacks. The engine stores these and calls them during Speak(). void SetOnAudio(AudioCallback cb); void SetOnBoundary(BoundaryCallback cb); + void SetOnMark(MarkCallback cb); void SetOnViseme(VisemeCallback cb); void SetOnError(ErrorCallback cb); @@ -72,6 +80,7 @@ class Engine { // Callbacks — stored as members so they live as long as the engine. AudioCallback m_onAudio; BoundaryCallback m_onBoundary; + MarkCallback m_onMark; VisemeCallback m_onViseme; ErrorCallback m_onError; @@ -82,7 +91,9 @@ class Engine { 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); + int32_t estimated, void* ud); + static void OnMarkThunk(const char* name, int32_t charOffset, + float startS, float endS, void* ud); static void OnVisemeThunk(int32_t visemeId, float offsetS, void* ud); static void OnErrorThunk(const char* msg, void* ud); }; diff --git a/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.cpp b/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.cpp index edd9246..76dd49b 100644 --- a/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.cpp +++ b/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.cpp @@ -95,6 +95,7 @@ bool Loader::Initialize() { ok &= GetFunc("tts_set_volume", setVolume); ok &= GetFunc("tts_set_on_audio", setOnAudio); ok &= GetFunc("tts_set_on_boundary", setOnBoundary); + ok &= GetFunc("tts_set_on_mark", setOnMark); ok &= GetFunc("tts_set_on_viseme", setOnViseme); ok &= GetFunc("tts_set_on_start", setOnStart); ok &= GetFunc("tts_set_on_end", setOnEnd); @@ -108,7 +109,14 @@ bool Loader::Initialize() { return false; } - spdlog::info("RustTts: all function pointers resolved"); + // ABI canary (issue #15): GetProcAddress cannot distinguish the old + // 4-arg tts_set_on_boundary (wrapper < 0.5.0, rust-tts-wrapper#31 + // consolidation) from the current 7-arg one — the symbol name is + // identical, but the old calling convention leaves charOffset/charLen/ + // estimated in never-set registers. tts_set_on_mark only exists in + // DLLs built with the consolidated callback ABI, so requiring it + // refuses to load any DLL old enough to be wrong. + spdlog::info("RustTts: all function pointers resolved (ABI canary: tts_set_on_mark present)"); return true; } diff --git a/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.h b/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.h index 20eb6bb..cc0f85a 100644 --- a/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.h +++ b/VoiceGardenSAPIAdapter/RustTts/RustTtsLoader.h @@ -16,6 +16,7 @@ struct tts_engine_info; typedef void (*CAudioCb)(const uint8_t*, uintptr_t, void*); typedef void (*CBoundaryCb)(const char*, int32_t, int32_t, float, float, int32_t, void*); +typedef void (*CMarkCb)(const char*, int32_t, float, float, void*); typedef void (*CVisemeCb)(int32_t, float, void*); typedef void (*CVoidCb)(void*); typedef void (*CErrorCb)(const char*, void*); @@ -42,6 +43,7 @@ class Loader { void (*setVolume)(tts_ctx*, float) = nullptr; void (*setOnAudio)(tts_ctx*, CAudioCb, void*) = nullptr; void (*setOnBoundary)(tts_ctx*, CBoundaryCb, void*) = nullptr; + void (*setOnMark)(tts_ctx*, CMarkCb, void*) = nullptr; void (*setOnViseme)(tts_ctx*, CVisemeCb, void*) = nullptr; void (*setOnStart)(tts_ctx*, CVoidCb, void*) = nullptr; void (*setOnEnd)(tts_ctx*, CVoidCb, void*) = nullptr; diff --git a/VoiceGardenSAPIAdapter/TTSEngine.cpp b/VoiceGardenSAPIAdapter/TTSEngine.cpp index 0e9c6d1..ecab4e2 100644 --- a/VoiceGardenSAPIAdapter/TTSEngine.cpp +++ b/VoiceGardenSAPIAdapter/TTSEngine.cpp @@ -827,6 +827,157 @@ static void MigrateLegacySherpaModelDir(const std::filesystem::path& modelsDir, } } +// Which local model families the floravox engine can load. floravox-core +// auto-detects the backend from the ONNX graph (piper/MMS VITS, Matcha +// +vocoder, Kokoro) and self-serves sherpa layout: tokens.txt, voices.bin +// and a sibling vocoder .onnx are read directly, and a piper `*.onnx.json` +// sidecar (shipped, or generated by the UI installer) only adds config. +// Flow/diffusion families (zipvoice, supertonic, pocket, kitten) are NOT +// floravox graphs and stay on sherpa-onnx. +static bool ModelSupportsFloravox(const std::filesystem::path& modelDir, + const std::string& modelId) +{ + // Family exclusion first: these carry tokens.txt too, so layout checks + // alone cannot keep them out. + std::string first = modelId; + for (auto& c : first) c = static_cast(::tolower(static_cast(c))); + static const char* kExcluded[] = { "zipvoice", "supertonic", "pocket", "kitten" }; + for (const char* ex : kExcluded) + if (first.find(ex) != std::string::npos) + return false; + + std::error_code ec; + bool hasSidecar = false, hasTokens = false, hasVoicesBin = false, hasVocoder = false; + for (const auto& entry : std::filesystem::directory_iterator(modelDir, ec)) + { + if (!entry.is_regular_file(ec)) + continue; + auto name = entry.path().filename().wstring(); + auto lower = name; + std::transform(lower.begin(), lower.end(), lower.begin(), ::towlower); + if (lower.size() > 10 && lower.ends_with(L".onnx.json")) + hasSidecar = true; + if (lower == L"tokens.txt") + hasTokens = true; + if (lower == L"voices.bin") + hasVoicesBin = true; + if (lower.ends_with(L".onnx") && + (lower.find(L"vocoder") != std::wstring::npos || + lower.find(L"hifigan") != std::wstring::npos || + lower.find(L"vocos") != std::wstring::npos)) + hasVocoder = true; + } + + // Kokoro (voices.bin), Matcha (sibling vocoder), and any VITS layout + // (piper sidecar or tokens.txt for MMS/char tables). + return hasVoicesBin || hasVocoder || hasSidecar || hasTokens; +} + +// ISO 639-3 → primary subtag for the MMS-style model ids the installer +// downloads (mms_eng, mms_fas, ...). Unknown codes yield "" (no lexicon +// bundle fetch; the model's own phonemizer still works). +static const char* Iso6393ToPrimary(const std::string& iso3) +{ + static const std::pair kMap[] = { + {"eng", "en"}, {"fas", "fa"}, {"pes", "fa"}, {"arb", "ar"}, {"spa", "es"}, + {"fra", "fr"}, {"deu", "de"}, {"por", "pt"}, {"ita", "it"}, {"rus", "ru"}, + {"zho", "zh"}, {"hin", "hi"}, {"jpn", "ja"}, {"kor", "ko"}, {"vie", "vi"}, + {"tur", "tr"}, {"pol", "pl"}, {"nld", "nl"}, {"swe", "sv"}, {"fin", "fi"}, + {"ces", "cs"}, {"ell", "el"}, {"heb", "he"}, {"ukr", "uk"}, {"hye", "hy"}, + }; + for (const auto& [code, primary] : kMap) + if (iso3 == code) + return primary; + return ""; +} + +// Credentials for the floravox engine: +// modelId — voice dir relative to the models root, forward slashes +// modelsDir— the models root (floravox resolves modelId under it) +// lang — primary language subtag; fetches the published lexicon + +// Phonetisaurus + ByT5 G2P bundle for that language (cached; +// fetch failure degrades to the model's own phonemizer). +// WITHOUT a language, floravox letter-spells every word +// (its G2P chain ends in letter spelling) — so this must be +// derivable for every routed voice. +// misaki — "us"/"gb" document-level English pre-pass (numbers and +// heteronyms come out right) +// fallbackLocale — the token's Attributes\Locale (e.g. "en-US"), used when +// the model id carries no language (coqui, matcha, …). +static std::string BuildFloravoxCredentials(const std::filesystem::path& modelsRoot, + const std::filesystem::path& rel, + const std::string& fallbackLocale = {}) +{ + std::string modelId = rel.generic_string(); // forward slashes for the Rust side + std::string modelsDir = modelsRoot.generic_string(); + + // First path component identifies the family and (usually) the language: + // piper-en_US-amy-low/… → "en" (+ "us" misaki) + // piper-fa_IR-amir-medium/… → "fa" + // mms_eng/… → "en" + // coqui-en-ljspeech/… → "en" + std::string first = rel.begin()->string(); + for (auto& c : first) c = static_cast(::tolower(static_cast(c))); + + std::string lang; + std::string misaki; + std::string localePart; // e.g. "en_us" / "fa_ir" — used for the misaki variant + + const auto dash1 = first.find('-'); + std::string family = dash1 == std::string::npos ? first : first.substr(0, dash1); + if (dash1 != std::string::npos) + { + auto rest = first.substr(dash1 + 1); + const auto dash2 = rest.find('-'); + localePart = dash2 == std::string::npos ? rest : rest.substr(0, dash2); + } + + if ((family == "piper" || family == "coqui") && !localePart.empty()) + { + const auto uscore = localePart.find('_'); + lang = uscore == std::string::npos ? localePart : localePart.substr(0, uscore); + } + else if (family == "mms" && !localePart.empty()) + { + lang = Iso6393ToPrimary(localePart); + } + else if (family == "kokoro") + { + // English Kokoro voices: misaki is the phonemizer Kokoro was trained + // with (heteronyms and numbers come out right) plus the en lexicon + // bundle. Multi-language voices (zh_en) keep the default chain. + if (localePart == "en") + { + lang = "en"; + misaki = "us"; + } + } + + // Fallback: the promoted token's locale (written from the catalog at + // promotion time) covers families whose ids carry no language. + if (lang.empty() && !fallbackLocale.empty()) + { + auto primary = fallbackLocale; + for (auto& c : primary) c = static_cast(::tolower(static_cast(c))); + const auto sep = primary.find_first_of("-_"); + if (sep != std::string::npos) + primary = primary.substr(0, sep); + if (primary.size() == 2 || primary.size() == 3) + lang = primary; + } + + if (lang == "en") + misaki = localePart.find("gb") != std::string::npos ? "gb" : "us"; + + std::string creds = "{\"modelId\":\"" + modelId + "\",\"modelsDir\":\"" + modelsDir + "\""; + if (!lang.empty()) + creds += ",\"lang\":\"" + lang + "\""; + if (!misaki.empty()) + creds += ",\"misaki\":\"" + misaki + "\""; + creds += "}"; + return creds; +} + bool CTTSEngine::InitRustTtsVoice(ISpDataKey* pConfigKey) { // Try to use rust-tts-wrapper for cloud engines. @@ -948,8 +1099,62 @@ bool CTTSEngine::InitRustTtsVoice(ISpDataKey* pConfigKey) // directory names so the wrapper's registry lookup succeeds. MigrateLegacySherpaModelDir(p, modelId, m_cpToken); std::replace(basePath.begin(), basePath.end(), '\\', '/'); - credsJson = "{\"modelId\":\"" + modelId + "\",\"modelPath\":\"" + basePath + "\"}"; - LogInfo("RustTts: SherpaOnnx credentials: {}", credsJson); + + // floravox-capable local voices (piper/MMS VITS, Matcha, + // Kokoro - see ModelSupportsFloravox) route through the + // floravox engine: measured word boundaries (duration tensor + // on patched voices), native SSML marks, and the published + // lexicon/Phonetisaurus/ByT5 G2P chain. Flow families + // (zipvoice/supertonic/pocket/kitten) and any floravox + // failure (e.g. the 32-bit DLL ships without floravox) fall + // back to the sherpa-onnx engine. + std::string floravoxCreds; + if (ModelSupportsFloravox(onnxPath.parent_path(), modelId)) + { + // Token locale (e.g. "en-US") — the catalog language for + // this voice, used when the model id carries none. + std::string tokenLocale; + if (m_cpToken) + { + CComPtr cpAttrs; + CSpDynamicString pszLocale; + if (SUCCEEDED(m_cpToken->OpenKey(L"Attributes", &cpAttrs)) && + SUCCEEDED(cpAttrs->GetStringValue(L"Locale", &pszLocale)) && + pszLocale.m_psz) + { + tokenLocale = WStringToUTF8(std::wstring(pszLocale.m_psz)); + } + } + floravoxCreds = BuildFloravoxCredentials(p, rel, tokenLocale); + LogInfo("RustTts: floravox candidate for '{}' (supported local layout, locale='{}')", + modelId, tokenLocale); + } + + if (!floravoxCreds.empty()) + { + m_rustTts = std::make_unique(); + if (m_rustTts->Create("floravox", floravoxCreds)) + { + m_rustTtsEngineId = "floravox"; + // floravox parses SSML natively (, , + // ) and reports offsets into the SSML document, + // so Speak() builds SSML and boundaries map through + // m_offsetMappings instead of m_plainToSapiMap. + m_rustTtsUseSsml = true; + LogInfo("RustTts: using floravox engine for piper voice '{}'", modelId); + } + else + { + LogWarn("RustTts: floravox engine unavailable for '{}', falling back to sherpaonnx", modelId); + m_rustTts.reset(); + } + } + + if (!m_rustTts) + { + credsJson = "{\"modelId\":\"" + modelId + "\",\"modelPath\":\"" + basePath + "\"}"; + LogInfo("RustTts: SherpaOnnx credentials: {}", credsJson); + } } else { @@ -970,17 +1175,24 @@ bool CTTSEngine::InitRustTtsVoice(ISpDataKey* pConfigKey) credsJson = "{\"apiKey\":\"" + key + "\"}"; } - // Create the RustTts engine - m_rustTts = std::make_unique(); - if (!m_rustTts->Create(lowerType, credsJson)) + // Create the RustTts engine (unless the floravox fast path above + // already created one for a piper-family voice) + if (!m_rustTts) { - LogWarn("RustTts: failed to create engine '{}', falling back", lowerType); - m_rustTts.reset(); - return false; + m_rustTts = std::make_unique(); + if (!m_rustTts->Create(lowerType, credsJson)) + { + LogWarn("RustTts: failed to create engine '{}', falling back", lowerType); + m_rustTts.reset(); + return false; + } + m_rustTtsEngineId = lowerType; } - // Set voice if specified - if (pszVoice.m_psz && *pszVoice.m_psz) + // Set voice if specified. floravox pins the voice via the modelId + // credential — a registry Voice value would override it per call with a + // selector the engine cannot resolve, so it is not forwarded. + if (pszVoice.m_psz && *pszVoice.m_psz && m_rustTtsEngineId != "floravox") { m_rustTts->SetVoice(WStringToUTF8(std::wstring(pszVoice.m_psz))); } @@ -998,19 +1210,44 @@ bool CTTSEngine::InitRustTtsVoice(ISpDataKey* pConfigKey) m_rustTts->SetOnBoundary([this](const char* word, int32_t charOffset, int32_t charLen, float startS, float endS, bool estimated) { - // Skip events with invalid offsets - if (charOffset < 0 || charLen <= 0) + // Skip events with invalid timing rather than corrupt the stream + if (startS < 0) return; - // Translate plain-text offsets (from Rust) → SAPI source offsets - ULONG sapiOffset = TranslateOffset(static_cast(charOffset)); - - LogInfo("RustTts boundary: word='{}' plainOffset={} sapiOffset={} len={} startS={:.3f}", - word ? word : "(null)", charOffset, sapiOffset, charLen, startS); - + // floravox measures timings from the model's duration tensor + // (estimated == false); sherpa-onnx interpolates them. Measured + // offsets are trusted verbatim — in particular they must never be + // shifted by the online silence/delay compensation, which is only + // meaningful for estimated timings (issue #15). uint64_t offsetTicks = static_cast(startS * 1e7); ULONGLONG audioBytes = WaveTicksToBytes(offsetTicks); + ULONG sapiOffset = 0; + ULONG sapiLen = static_cast(charLen > 0 ? charLen : 0); + if (charOffset >= 0) + { + if (m_rustTtsUseSsml) + { + // SSML mode: Rust reports offsets into the built SSML + // document; map them back to SAPI source offsets. + ULONG ssmlOffset = static_cast(charOffset); + MapTextOffset(ssmlOffset, sapiLen); + sapiOffset = ssmlOffset; + } + else + { + // Plain-text mode: offsets index the extracted plain text. + sapiOffset = TranslateOffset(static_cast(charOffset)); + } + } + // charOffset < 0: no text anchor (e.g. measured boundary for text the + // frontend rewrote). Emit an audio-time-only event instead of losing + // the timing (issue #15 nit): lParam 0 marks "no source position". + + LogInfo("RustTts boundary: word='{}' plainOffset={} sapiOffset={} len={} startS={:.3f} {}", + word ? word : "(null)", charOffset, sapiOffset, sapiLen, startS, + estimated ? "estimated" : "measured"); + std::lock_guard lock(m_outputSiteMutex); if (!m_pOutputSite) return; @@ -1020,7 +1257,51 @@ bool CTTSEngine::InitRustTtsVoice(ISpDataKey* pConfigKey) ev.eEventId = SPEI_WORD_BOUNDARY; ev.elParamType = SPET_LPARAM_IS_UNDEFINED; ev.lParam = static_cast(sapiOffset); - ev.wParam = static_cast(charLen); + ev.wParam = static_cast(sapiLen); + m_pOutputSite->AddEvents(&ev, 1); + }); + + // floravox fires marks for SSML — deliver them as + // real SPEI_TTS_BOOKMARK events and retire the matching simulated ones. + m_rustTts->SetOnMark([this](const char* name, int32_t charOffset, + float startS, float /*endS*/) { + if (startS < 0 || !name) + return; + + uint64_t offsetTicks = static_cast(startS * 1e7); + ULONGLONG audioBytes = WaveTicksToBytes(offsetTicks); + + std::wstring nameW = UTF8ToWString(name); + ULONG sapiOffset = 0; + if (charOffset >= 0 && m_rustTtsUseSsml) + { + ULONG ssmlOffset = static_cast(charOffset); + ULONG len = 0; + MapTextOffset(ssmlOffset, len); + sapiOffset = ssmlOffset; + } + + LogInfo("RustTts mark: name='{}' sapiOffset={} startS={:.3f}", name, sapiOffset, startS); + + // A real mark supersedes any queued simulation for the same name. + for (size_t i = m_bookmarkIndex; i < m_bookmarks.size(); ++i) + { + if (m_bookmarks[i].name == nameW) + { + m_bookmarkIndex = i + 1; + break; + } + } + + std::lock_guard lock(m_outputSiteMutex); + if (!m_pOutputSite) return; + SPEVENT ev; + ZeroMemory(&ev, sizeof(ev)); + ev.ullAudioStreamOffset = audioBytes; + ev.eEventId = SPEI_TTS_BOOKMARK; + ev.elParamType = SPET_LPARAM_IS_STRING; + ev.lParam = reinterpret_cast(nameW.c_str()); + ev.wParam = _wtol(nameW.c_str()); m_pOutputSite->AddEvents(&ev, 1); }); @@ -1033,9 +1314,16 @@ bool CTTSEngine::InitRustTtsVoice(ISpDataKey* pConfigKey) LogErr("RustTts engine error: {}", msg ? msg : "(null)"); }); - 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)"); + // is only meaningful for online engines; floravox + // would fail to resolve the name against its models dir. + if (m_rustTtsEngineId != "floravox") + m_onlineVoiceName = pszVoice.m_psz ? pszVoice.m_psz : L""; + // SSML mode is opt-in per engine: floravox sets it in its fast path + // (native // + marks); everything else sends + // plain text and lets the wrapper build markup internally. + LogInfo("RustTts voice created: {} (engine '{}', ssml={}) / {}", engineType, + m_rustTtsEngineId, m_rustTtsUseSsml, + pszVoice.m_psz ? pszVoice.m_psz : L"(default)"); return true; } @@ -1516,10 +1804,22 @@ bool CTTSEngine::BuildSSML(const SPVTEXTFRAG* pTextFragList) m_ssml.append(L"ms'/>"); break; - case SPVA_Bookmark: // insert a - m_ssml.append(L""); + case SPVA_Bookmark: + // floravox parses SSML-standard ; the + // Microsoft dialect is only meaningful + // for Azure-family engines. + if (m_rustTtsEngineId == "floravox") + { + m_ssml.append(L""); + } + else + { + m_ssml.append(L""); + } // keep track of every bookmark, so when there's no text, we can simulate bookmark events instead m_bookmarks.emplace_back(pTextFrag->ulTextSrcOffset, std::wstring(pTextFrag->pTextStart, pTextFrag->ulTextLen)); break; diff --git a/VoiceGardenSAPIAdapter/TTSEngine.h b/VoiceGardenSAPIAdapter/TTSEngine.h index a2cbb98..f305aa8 100644 --- a/VoiceGardenSAPIAdapter/TTSEngine.h +++ b/VoiceGardenSAPIAdapter/TTSEngine.h @@ -117,6 +117,8 @@ END_COM_MAP() CComPtr m_phoneConverter; std::unique_ptr m_rustTts; bool m_rustTtsUseSsml = false; + // rust-tts-wrapper engine id in use ("floravox", "sherpaonnx", "azure", ...). + std::string m_rustTtsEngineId; std::future m_lastCancellingFuture; // Boundary events queued during synthesis, delivered via SAPI AddEvents diff --git a/scripts/Get-RustTtsWrapperDll.ps1 b/scripts/Get-RustTtsWrapperDll.ps1 new file mode 100644 index 0000000..0fe03e1 --- /dev/null +++ b/scripts/Get-RustTtsWrapperDll.ps1 @@ -0,0 +1,53 @@ +# Resolves the rust_tts_wrapper.dll path for a RID from the NuGet cache, +# pinned to the exact RustTtsWrapper.Bindings version referenced by +# VoiceGarden.UI.csproj. Never falls back to "whatever is cached" — a +# mismatch between the csproj pin and the shipped DLL is an ABI hazard +# (rust-tts-wrapper#31 boundary-callback consolidation). +# +# Usage: +# $dll = & scripts\Get-RustTtsWrapperDll.ps1 -Rid win-x64 [-Csproj path] +# if ($dll) { Copy-Item $dll ... } else { # error handling } +[CmdletBinding()] +param( + [Parameter(Mandatory)] [ValidateSet('win-x64', 'win-x86', 'win-arm64')] [string]$Rid, + [string]$Csproj = "$PSScriptRoot\..\VoiceGarden.UI\VoiceGarden.UI.csproj", + # Override for tests (point at a fabricated package cache). + [string]$PackagesDir = (Join-Path $env:USERPROFILE ".nuget\packages") +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $Csproj)) { throw "csproj not found: $Csproj" } + +# PackageId case in the cache dir is lowercased on all NuGet hosts we use. +$pkgId = 'rustttswrapper.bindings' + +# Parse the pinned version (honours VersionOverride-style children too). +$project = [xml](Get-Content $Csproj -Raw) +$pkgVersion = $null +foreach ($group in $project.Project.ItemGroup.PackageReference | Where-Object { $_ }) { + $ref = @($group) | Where-Object { $_.Include -eq 'RustTtsWrapper.Bindings' } + if ($ref) { $pkgVersion = $ref.Version; break } +} +if (-not $pkgVersion) { throw "RustTtsWrapper.Bindings not referenced in $Csproj" } + +# Strip NuGet floating-version suffixes (e.g. "0.5.*" -> not supported: pin exactly). +if ($pkgVersion -match '[*^~]') { + throw "RustTtsWrapper.Bindings version '$pkgVersion' is floating; pin an exact version (ABI canary)." +} + +$pkgDir = Join-Path $PackagesDir "$pkgId\$pkgVersion" +$dll = Join-Path $pkgDir "runtimes\$Rid\native\rust_tts_wrapper.dll" + +if (Test-Path $dll) { + Write-Verbose "Resolved $Rid -> $dll" + Write-Output $dll + return +} + +if (-not (Test-Path $pkgDir)) { + Write-Warning "RustTtsWrapper.Bindings $pkgVersion is not in the NuGet cache. Run: dotnet restore $Csproj" +} else { + Write-Warning "Package $pkgVersion restored but has no runtimes\$Rid\native\rust_tts_wrapper.dll" +} +Write-Output $null diff --git a/scripts/build-release-local.ps1 b/scripts/build-release-local.ps1 index 1201b4b..7ea318f 100644 --- a/scripts/build-release-local.ps1 +++ b/scripts/build-release-local.ps1 @@ -415,28 +415,22 @@ foreach ($Platform in $Platforms) { Copy-Item $mainOut\* $platformPayload\ -Recurse -Force } -# Rust TTS wrapper native DLL (from NuGet package cache) — CI copies this in -# the setup-composition job (msbuild.yml); the local build had no equivalent, -# producing payloads without rust_tts_wrapper.dll. +# Rust TTS wrapper native DLL — pinned to the csproj's RustTtsWrapper.Bindings +# version via the shared resolver (issue #15: shipping a cached-but-unpinned +# DLL risks a callback-ABI mismatch with the thunk code). $rustDllDir = Join-Path $env:USERPROFILE ".nuget\packages\rustttswrapper.bindings" if (Test-Path $rustDllDir) { foreach ($rid in @("win-x64", "win-x86")) { $arch = $rid -replace 'win-', '' $targetDir = Join-Path $PayloadDir $arch if (-not (Test-Path $targetDir)) { continue } - $dll = Get-ChildItem -Path $rustDllDir -Recurse -Filter "rust_tts_wrapper.dll" | - Where-Object { $_.FullName -like "*\runtimes\$rid\native\*" } | - Sort-Object { - $ver = $_.FullName.Substring($rustDllDir.Length + 1).Split('\')[0] - try { [version]$ver } catch { [version]($ver -replace '-.*$', '') } - } -Descending | - Select-Object -First 1 + $dll = & "$PSScriptRoot\Get-RustTtsWrapperDll.ps1" -Rid $rid if ($dll) { - Copy-Item $dll.FullName $targetDir -Force - $dllVersion = $dll.FullName.Substring($rustDllDir.Length + 1).Split('\')[0] - Write-Host " rust_tts_wrapper.dll $dllVersion ($rid) -> payload\$arch\" -ForegroundColor DarkGray + Copy-Item $dll $targetDir -Force + $dllVersion = $dll.Substring($rustDllDir.Length + 1).Split('\')[0] + Write-Host " rust_tts_wrapper.dll $dllVersion ($rid, csproj-pinned) -> payload\$arch\" -ForegroundColor DarkGray } else { - Write-Host " WARNING: rust_tts_wrapper.dll not found for $rid in NuGet cache" -ForegroundColor Yellow + Write-Host " WARNING: rust_tts_wrapper.dll for $rid not resolvable (see resolver warnings)" -ForegroundColor Yellow } } } else { diff --git a/scripts/test-rust-abi.ps1 b/scripts/test-rust-abi.ps1 new file mode 100644 index 0000000..36e539a --- /dev/null +++ b/scripts/test-rust-abi.ps1 @@ -0,0 +1,120 @@ +# ABI test for the shipped rust_tts_wrapper.dll (issue #15). +# +# Verifies, WITHOUT loading it into this process: +# 1. Every symbol the C++ loader resolves is exported. +# 2. The ABI canary symbol (tts_set_on_mark) is present — it only exists +# in DLLs built with the consolidated 7-arg boundary callback +# (rust-tts-wrapper#31). A DLL that fails this check would deliver +# garbage charOffset/charLen to the boundary lambda. +# 3. Negative test: a pre-consolidation DLL (0.3.16 in the NuGet cache, +# if present) must FAIL the same check. +# +# Usage: pwsh -File scripts\test-rust-abi.ps1 [-DllPath path] +# (default: csproj-pinned package via Get-RustTtsWrapperDll.ps1) +param([string]$DllPath) + +$ErrorActionPreference = 'Stop' +$failures = 0 + +function It($name, [scriptblock]$body) { + try { + & $body + Write-Host " PASS $name" -ForegroundColor Green + } catch { + $script:failures++ + Write-Host " FAIL $name : $($_.Exception.Message)" -ForegroundColor Red + } +} + +function Assert-True($cond, $msg) { if (-not $cond) { throw $msg } } + +function Get-Exports([string]$path) { + # Parse PE export table via .NET — no dumpbin dependency. + $bytes = [System.IO.File]::ReadAllBytes($path) + $peOffset = [BitConverter]::ToInt32($bytes, 0x3C) + Assert-True ($bytes[$peOffset] -eq 0x50 -and $bytes[$peOffset + 1] -eq 0x45) "not a PE file" + $machine = [BitConverter]::ToUInt16($bytes, $peOffset + 4) + $optHeaderOffset = $peOffset + 24 + $magic = [BitConverter]::ToUInt16($bytes, $optHeaderOffset) + $is64 = ($magic -eq 0x20B) + $dataDirOffset = $optHeaderOffset + $(if ($is64) { 112 } else { 96 }) + $exportRva = [BitConverter]::ToUInt32($bytes, $dataDirOffset) + # Section table to convert RVA -> file offset + $numSections = [BitConverter]::ToUInt16($bytes, $peOffset + 6) + $sizeOfOptional = [BitConverter]::ToUInt16($bytes, $peOffset + 20) + $sectionsOffset = $peOffset + 24 + $sizeOfOptional + function RvaToOffset([uint32]$rva) { + for ($s = 0; $s -lt $numSections; $s++) { + $so = $sectionsOffset + $s * 40 + $vaddr = [BitConverter]::ToUInt32($bytes, $so + 12) + $vsize = [BitConverter]::ToUInt32($bytes, $so + 8) + $rawPtr = [BitConverter]::ToUInt32($bytes, $so + 20) + if ($rva -ge $vaddr -and $rva -lt ($vaddr + $vsize)) { + return [int]($rawPtr + ($rva - $vaddr)) + } + } + throw "RVA 0x$($rva.ToString('X')) not in any section" + } + if ($exportRva -eq 0) { return @{} } + $dir = RvaToOffset $exportRva + $numNames = [BitConverter]::ToUInt32($bytes, $dir + 24) + $namesRva = [BitConverter]::ToUInt32($bytes, $dir + 32) + $namesOff = RvaToOffset $namesRva + $exports = @{} + for ($i = 0; $i -lt $numNames; $i++) { + $nameRva = [BitConverter]::ToUInt32($bytes, $namesOff + $i * 4) + $nameOff = RvaToOffset $nameRva + $end = $nameOff + while ($bytes[$end] -ne 0) { $end++ } + $name = [System.Text.Encoding]::ASCII.GetString($bytes, $nameOff, $end - $nameOff) + $exports[$name] = $true + } + return $exports +} + +# Resolve the DLL under test +if (-not $DllPath) { + $DllPath = & "$PSScriptRoot\Get-RustTtsWrapperDll.ps1" -Rid win-x64 + if (-not $DllPath) { throw "could not resolve the pinned rust_tts_wrapper.dll (run dotnet restore first)" } +} +Write-Host "ABI check: $DllPath" + +$required = @( + 'tts_create', 'tts_destroy', 'tts_speak', 'tts_speak_ssml', 'tts_speak_sync', + 'tts_stop', 'tts_set_voice', 'tts_set_rate', 'tts_set_pitch', 'tts_set_volume', + 'tts_set_on_audio', 'tts_set_on_boundary', 'tts_set_on_viseme', + 'tts_set_on_start', 'tts_set_on_end', 'tts_set_on_error', 'tts_get_last_error' +) +$canary = 'tts_set_on_mark' +$floravox = 'tts_get_engines' # enumeration API needed for engine discovery + +It 'exports every symbol the C++ loader resolves' { + $exports = Get-Exports $DllPath + foreach ($sym in $required) { + Assert-True ($exports.ContainsKey($sym)) "missing export: $sym" + } +} + +It 'ABI canary: tts_set_on_mark present (consolidated boundary callback)' { + $exports = Get-Exports $DllPath + Assert-True ($exports.ContainsKey($canary)) "$canary missing - DLL predates the 7-arg boundary ABI; the loader would read garbage offsets" +} + +It 'exports tts_get_engines (engine enumeration)' { + $exports = Get-Exports $DllPath + Assert-True ($exports.ContainsKey($floravox)) "$floravox missing" +} + +# Negative control: the oldest cached package must fail the canary. +$oldDll = Get-ChildItem "$env:USERPROFILE\.nuget\packages\rustttswrapper.bindings\0.3.*\runtimes\win-x64\native\rust_tts_wrapper.dll" -ErrorAction SilentlyContinue | + Sort-Object FullName | Select-Object -First 1 +if ($oldDll) { + It "negative control: pre-consolidation DLL ($($oldDll.FullName.Split('\')[-4])) is rejected by the canary" { + $exports = Get-Exports $oldDll.FullName + Assert-True (-not $exports.ContainsKey($canary)) "old DLL unexpectedly has $canary - the canary no longer discriminates!" + } +} else { + Write-Host " SKIP negative control (no 0.3.x package cached)" -ForegroundColor DarkGray +} + +if ($failures) { exit 1 } else { Write-Host "All ABI tests passed." -ForegroundColor Green; exit 0 } diff --git a/scripts/test-rust-dll-pin.ps1 b/scripts/test-rust-dll-pin.ps1 new file mode 100644 index 0000000..d5de232 --- /dev/null +++ b/scripts/test-rust-dll-pin.ps1 @@ -0,0 +1,72 @@ +# Tests for scripts\Get-RustTtsWrapperDll.ps1 (issue #15: CI must ship the +# csproj-pinned rust_tts_wrapper.dll, never "whatever is cached"). +# +# Usage: pwsh -File scripts\test-rust-dll-pin.ps1 (exit 0 = pass) +$ErrorActionPreference = 'Stop' +$script = Join-Path $PSScriptRoot 'Get-RustTtsWrapperDll.ps1' +$temp = Join-Path ([System.IO.Path]::GetTempPath()) "vg-rust-pin-test-$(Get-Random)" +$failures = 0 + +function It($name, [scriptblock]$body) { + try { + & $body + Write-Host " PASS $name" -ForegroundColor Green + } catch { + $script:failures++ + Write-Host " FAIL $name : $($_.Exception.Message)" -ForegroundColor Red + } +} + +function Assert-True($cond, $msg) { if (-not $cond) { throw $msg } } +function Assert-Throw($block, $msg) { + try { & $block } catch { return } + throw $msg +} + +try { + New-Item -ItemType Directory -Force -Path "$temp\cache" | Out-Null + + # Fake csproj pinning 9.9.9 + $csproj = "$temp\fake.csproj" + @' + + + + + +'@ | Set-Content $csproj + + # Fake package cache with 1.0.0 (stale) and 9.9.9 (pinned) + foreach ($v in '1.0.0', '9.9.9') { + $dir = "$temp\cache\rustttswrapper.bindings\$v\runtimes\win-x64\native" + New-Item -ItemType Directory -Force -Path $dir | Out-Null + Set-Content (Join-Path $dir 'rust_tts_wrapper.dll') "fake-$v" + } + + It 'resolves the pinned version, not the cached newest/oldest' { + $dll = & $script -Rid win-x64 -Csproj $csproj -PackagesDir "$temp\cache" + Assert-True ($dll -like '*\9.9.9\runtimes\win-x64\native\rust_tts_wrapper.dll') "got: $dll" + } + + It 'returns $null when the pinned version is not restored' { + $csproj2 = "$temp\fake-v8.csproj" + (Get-Content $csproj -Raw) -replace '9\.9\.9', '8.8.8' | Set-Content $csproj2 + $dll = & $script -Rid win-x64 -Csproj $csproj2 -PackagesDir "$temp\cache" 3>$null + Assert-True (-not $dll) "expected no result, got: $dll" + } + + It 'returns $null when the RID is missing from the pinned package' { + $dll = & $script -Rid win-x86 -Csproj $csproj -PackagesDir "$temp\cache" 3>$null + Assert-True (-not $dll) "expected no result for win-x86, got: $dll" + } + + It 'rejects floating versions' { + $csproj3 = "$temp\fake-float.csproj" + (Get-Content $csproj -Raw) -replace '9\.9\.9', '9.9.*' | Set-Content $csproj3 + Assert-Throw { & $script -Rid win-x64 -Csproj $csproj3 -PackagesDir "$temp\cache" | Out-Null } 'floating version should throw' + } +} finally { + Remove-Item -Recurse -Force $temp -ErrorAction SilentlyContinue +} + +if ($failures) { exit 1 } else { Write-Host "All pin tests passed." -ForegroundColor Green; exit 0 }