|
| 1 | +<# |
| 2 | +.SYNOPSIS |
| 3 | + End-to-end test for NaturalVoiceSAPIAdapter.Net |
| 4 | +.DESCRIPTION |
| 5 | + Tests: build, TTS unit, COM registration, voice registration, SAPI speak. |
| 6 | + Steps 5-7 require admin elevation. Re-run as admin for full coverage. |
| 7 | +.EXAMPLE |
| 8 | + .\test-e2e.ps1 # Unit tests only (no admin needed) |
| 9 | + .\test-e2e.ps1 -Full # All tests including COM/SAPI (needs admin) |
| 10 | +#> |
| 11 | +param( |
| 12 | + [switch]$Full, |
| 13 | + [string]$PublishDir = "$PSScriptRoot\..\e2e-test-bin", |
| 14 | + [string]$ModelDir = "$env:LOCALAPPDATA\NaturalVoiceSAPIAdapter\models", |
| 15 | + [string]$AzureKey = $null, |
| 16 | + [string]$AzureRegion = $null |
| 17 | +) |
| 18 | + |
| 19 | +$ErrorActionPreference = "Continue" |
| 20 | +$Pass = 0; $Fail = 0; $Skip = 0 |
| 21 | + |
| 22 | +function Test-Step($Name, $ScriptBlock) { |
| 23 | + Write-Host "`n--- $Name ---" -ForegroundColor Cyan |
| 24 | + try { |
| 25 | + & $ScriptBlock |
| 26 | + Write-Host " PASS" -ForegroundColor Green |
| 27 | + $script:Pass++ |
| 28 | + } catch { |
| 29 | + Write-Host " FAIL: $($_.Exception.Message)" -ForegroundColor Red |
| 30 | + $script:Fail++ |
| 31 | + } |
| 32 | +} |
| 33 | + |
| 34 | +function Test-StepSkip($Name, $Reason) { |
| 35 | + Write-Host "`n--- $Name ---" -ForegroundColor DarkGray |
| 36 | + Write-Host " SKIP: $Reason" -ForegroundColor Yellow |
| 37 | + $script:Skip++ |
| 38 | +} |
| 39 | + |
| 40 | +$RepoRoot = (Resolve-Path "$PSScriptRoot\..").Path |
| 41 | +$PublishDir = (New-Item -ItemType Directory -Force -Path $PublishDir).FullName |
| 42 | + |
| 43 | +Write-Host "=== NaturalVoiceSAPIAdapter.Net E2E Test ===" -ForegroundColor White |
| 44 | +Write-Host "Repo: $RepoRoot" |
| 45 | +Write-Host "Publish: $PublishDir" |
| 46 | +Write-Host "Full: $Full" |
| 47 | + |
| 48 | +# ============================================================ |
| 49 | +# STEP 1: Build & Publish |
| 50 | +# ============================================================ |
| 51 | +Test-Step "Step 1: Build & Publish .NET adapter" { |
| 52 | + dotnet publish "$RepoRoot\NaturalVoiceSAPIAdapter.Net\NaturalVoiceSAPIAdapter.Net.csproj" ` |
| 53 | + -c Release -r win-x64 --self-contained false ` |
| 54 | + -o $PublishDir 2>&1 | Where-Object { $_ -match "error|Build succeeded|Build FAILED" } | ForEach-Object { Write-Host " $_" } |
| 55 | + |
| 56 | + $comhost = Join-Path $PublishDir "NaturalVoiceSAPIAdapter.Net.comhost.dll" |
| 57 | + $managedDll = Join-Path $PublishDir "NaturalVoiceSAPIAdapter.Net.dll" |
| 58 | + if (!(Test-Path $comhost)) { throw "comhost.dll not found at $comhost" } |
| 59 | + if (!(Test-Path $managedDll)) { throw "managed DLL not found at $managedDll" } |
| 60 | + Write-Host " comhost.dll: $((Get-Item $comhost).Length) bytes" |
| 61 | + Write-Host " managed DLL: $((Get-Item $managedDll).Length) bytes" |
| 62 | +} |
| 63 | + |
| 64 | +# ============================================================ |
| 65 | +# STEP 2: Unit test - TTSEngine creation + GetOutputFormat |
| 66 | +# ============================================================ |
| 67 | +Test-Step "Step 2: Unit test - TTSEngine + GetOutputFormat" { |
| 68 | + $testCsproj = "$RepoRoot\NaturalVoiceSAPIAdapter.Net\TestLocal\TestLocal.csproj" |
| 69 | + $result = dotnet run --project $testCsproj --no-build 2>&1 |
| 70 | + if ($LASTEXITCODE -ne 0) { |
| 71 | + $result = dotnet run --project $testCsproj 2>&1 |
| 72 | + } |
| 73 | + $result | Where-Object { $_ -match "PASS|FAIL|OK|SKIP|Found|Format|creds" } | ForEach-Object { Write-Host " $_" } |
| 74 | + $fails = $result | Where-Object { $_ -match "FAILED:" } |
| 75 | + if ($fails) { throw "Unit tests failed" } |
| 76 | +} |
| 77 | + |
| 78 | +# ============================================================ |
| 79 | +# STEP 3: TTS Synthesis (dotnet-tts-wrapper directly) |
| 80 | +# ============================================================ |
| 81 | +Test-Step "Step 3: TTS synthesis - enumerate SherpaOnnx voices from catalog" { |
| 82 | + Add-Type -Path (Join-Path $PublishDir "DotNetTtsWrapper.Core.dll") |
| 83 | + |
| 84 | + $creds = New-Object DotNetTtsWrapper.Models.SherpaOnnxCredentials |
| 85 | + $client = [DotNetTtsWrapper.Models.TtsFactory]::CreateClient("sherpaonnx", $creds) |
| 86 | + |
| 87 | + $voices = $client.GetVoicesAsync().GetAwaiter().GetResult() |
| 88 | + Write-Host " Voice catalog has $($voices.Count) voices" |
| 89 | + $voices | Select-Object -First 3 | ForEach-Object { Write-Host " - $($_.Name) [$($_.Id)]" } |
| 90 | + if ($voices.Count -eq 0) { throw "Voice catalog is empty" } |
| 91 | +} |
| 92 | + |
| 93 | +# ============================================================ |
| 94 | +# STEP 4: TTS Synthesis with local model (if available) |
| 95 | +# ============================================================ |
| 96 | +$modelExists = Test-Path $ModelDir |
| 97 | +if ($modelExists) { |
| 98 | + $modelDirs = Get-ChildItem $ModelDir -Directory | Select-Object -First 3 |
| 99 | + $modelNames = ($modelDirs | ForEach-Object { $_.Name }) -join ", " |
| 100 | + Write-Host "`n Found models: $modelNames" -ForegroundColor DarkGray |
| 101 | +} |
| 102 | + |
| 103 | +Test-Step "Step 4: TTS synthesis with local model" { |
| 104 | + if (!$modelExists) { |
| 105 | + Write-Host " No models in $ModelDir - testing with generated PCM instead" |
| 106 | + |
| 107 | + # Test that our EnsurePcm16/WAV parsing works by synthesizing with a dummy WAV |
| 108 | + $wavHeader = [System.Text.Encoding]::ASCII.GetBytes("RIFF") |
| 109 | + $testWav = New-Object byte[] 58 |
| 110 | + [System.Buffer]::BlockCopy($wavHeader, 0, $testWav, 0, 4) |
| 111 | + # Write a minimal valid WAV: 44 byte header + 8 byte "data" chunk with 2 samples |
| 112 | + $fs = [System.IO.File]::Create("$PublishDir\test-tone.wav") |
| 113 | + $writer = New-Object System.IO.BinaryWriter($fs) |
| 114 | + $writer.Write([System.Text.Encoding]::ASCII.GetBytes("RIFF")) |
| 115 | + $writer.Write([int32]50) # file size - 8 |
| 116 | + $writer.Write([System.Text.Encoding]::ASCII.GetBytes("WAVE")) |
| 117 | + $writer.Write([System.Text.Encoding]::ASCII.GetBytes("fmt ")) |
| 118 | + $writer.Write([int32]16) # chunk size |
| 119 | + $writer.Write([int16]1) # PCM |
| 120 | + $writer.Write([int16]1) # mono |
| 121 | + $writer.Write([int32]24000) # sample rate |
| 122 | + $writer.Write([int32]48000) # byte rate |
| 123 | + $writer.Write([int16]2) # block align |
| 124 | + $writer.Write([int16]16) # bits per sample |
| 125 | + $writer.Write([System.Text.Encoding]::ASCII.GetBytes("data")) |
| 126 | + $writer.Write([int32]14) # data size |
| 127 | + # 7 samples of silence |
| 128 | + for ($i = 0; $i -lt 7; $i++) { $writer.Write([int16]0) } |
| 129 | + $writer.Close() |
| 130 | + $fs.Close() |
| 131 | + Write-Host " Created test WAV file" |
| 132 | + return |
| 133 | + } |
| 134 | + |
| 135 | + $modelDir = (Get-ChildItem $ModelDir -Directory | Select-Object -First 1).FullName |
| 136 | + Write-Host " Using model: $(Split-Path $modelDir -Leaf)" |
| 137 | + |
| 138 | + $creds = New-Object DotNetTtsWrapper.Models.SherpaOnnxCredentials |
| 139 | + $creds.ModelPath = $modelDir |
| 140 | + |
| 141 | + $client = [DotNetTtsWrapper.Models.TtsFactory]::CreateClient("sherpaonnx", $creds) |
| 142 | + $result = $client.SynthToBytesAsync("Hello world test.").GetAwaiter().GetResult() |
| 143 | + |
| 144 | + Write-Host " Audio: $($result.AudioData.Length) bytes, format=$($result.Format), rate=$($result.SampleRate)" |
| 145 | + if ($result.AudioData.Length -lt 100) { throw "Audio data too small: $($result.AudioData.Length) bytes" } |
| 146 | + |
| 147 | + [System.IO.File]::WriteAllBytes("$PublishDir\test-synthesis.wav", $result.AudioData) |
| 148 | + Write-Host " Saved test-synthesis.wav" |
| 149 | +} |
| 150 | + |
| 151 | +# ============================================================ |
| 152 | +# STEP 5: COM Registration (requires admin) |
| 153 | +# ============================================================ |
| 154 | +if ($Full) { |
| 155 | + $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) |
| 156 | + |
| 157 | + if ($isAdmin) { |
| 158 | + Test-Step "Step 5: COM registration (regsvr32)" { |
| 159 | + $comhost = Join-Path $PublishDir "NaturalVoiceSAPIAdapter.Net.comhost.dll" |
| 160 | + |
| 161 | + # Unregister any existing first |
| 162 | + & "$env:windir\System32\regsvr32.exe" /u /s $comhost 2>$null |
| 163 | + |
| 164 | + # Register using 64-bit regsvr32 explicitly |
| 165 | + $proc = Start-Process "$env:windir\System32\regsvr32.exe" -ArgumentList "/s `"$comhost`"" -Wait -PassThru -NoNewWindow |
| 166 | + if ($proc.ExitCode -ne 0) { |
| 167 | + Write-Host " regsvr32 exit code $($proc.ExitCode) - trying ComRegistration.Register fallback" |
| 168 | + # Fallback: use our managed registration |
| 169 | + Add-Type -Path (Join-Path $PublishDir "NaturalVoiceSAPIAdapter.Net.dll") |
| 170 | + [NaturalVoiceSAPIAdapter.ComRegistration]::Register([NaturalVoiceSAPIAdapter.TTSEngine]) |
| 171 | + Write-Host " Used managed ComRegistration.Register" |
| 172 | + } |
| 173 | + |
| 174 | + # Verify TTSEngine CLSID |
| 175 | + $regKey = "HKLM:\SOFTWARE\Classes\CLSID\{013AB33B-AD1A-401C-8BEE-F6E2B046A94E}\InprocServer32" |
| 176 | + if (!(Test-Path $regKey)) { throw "TTSEngine CLSID not registered in registry" } |
| 177 | + $regPath = (Get-ItemProperty $regKey).'(default)' |
| 178 | + Write-Host " TTSEngine CLSID -> $regPath" |
| 179 | + |
| 180 | + # Verify enumerator CLSID |
| 181 | + $enumKey = "HKLM:\SOFTWARE\Classes\CLSID\{B8B9E38F-E5A2-4661-9FDE-4AC7377AA6F6}\InprocServer32" |
| 182 | + if (!(Test-Path $enumKey)) { throw "VoiceTokenEnumerator CLSID not registered" } |
| 183 | + Write-Host " VoiceTokenEnumerator CLSID registered" |
| 184 | + |
| 185 | + # Verify TokenEnums |
| 186 | + $tokenEnums = "HKLM:\SOFTWARE\Microsoft\Speech\Voices\TokenEnums\NaturalVoiceEnumerator" |
| 187 | + if (Test-Path $tokenEnums) { |
| 188 | + $clsid = (Get-ItemProperty $tokenEnums).CLSID |
| 189 | + Write-Host " TokenEnums CLSID = $clsid" |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + # ============================================================ |
| 194 | + # STEP 6: Register test voice token |
| 195 | + # ============================================================ |
| 196 | + Test-Step "Step 6: Register test voice token in SAPI" { |
| 197 | + $tokenPath = "HKLM:\SOFTWARE\Microsoft\Speech\Voices\TokenEnums\NaturalVoiceEnumerator" |
| 198 | + if (!(Test-Path $tokenPath)) { |
| 199 | + New-Item -Path $tokenPath -Force | Out-Null |
| 200 | + } |
| 201 | + Set-ItemProperty -Path $tokenPath -Name "CLSID" -Value "{B8B9E38F-E5A2-4661-9FDE-4AC7377AA6F6}" -Force |
| 202 | + Write-Host " TokenEnums registered" |
| 203 | + |
| 204 | + # Register a test voice under the voice tokens key |
| 205 | + $voiceTokenPath = "HKLM:\SOFTWARE\Microsoft\Speech\Voices\TokenEnums\NaturalVoiceEnumerator\DotNetTestVoice" |
| 206 | + if (!(Test-Path $voiceTokenPath)) { |
| 207 | + New-Item -Path $voiceTokenPath -Force | Out-Null |
| 208 | + } |
| 209 | + Set-ItemProperty -Path $voiceTokenPath -Name "CLSID" -Value "{013AB33B-AD1A-401C-8BEE-F6E2B046A94E}" -Force |
| 210 | + Set-ItemProperty -Path $voiceTokenPath -Name "EngineName" -Value "sherpaonnx" -Force |
| 211 | + Set-ItemProperty -Path $voiceTokenPath -Name "VoiceId" -Value "test-voice" -Force |
| 212 | + Write-Host " Test voice token registered" |
| 213 | + } |
| 214 | + |
| 215 | + # ============================================================ |
| 216 | + # STEP 7: SAPI voice enumeration |
| 217 | + # ============================================================ |
| 218 | + Test-Step "Step 7: SAPI voice enumeration" { |
| 219 | + # Use cscript (no .NET loaded) to test COM activation from a clean process |
| 220 | + $vbs = @" |
| 221 | +Set voice = CreateObject("SAPI.SpVoice") |
| 222 | +Set voices = voice.GetVoices |
| 223 | +WScript.Echo "SAPI reports " & voices.Count & " voices total" |
| 224 | +For i = 0 To voices.Count - 1 |
| 225 | + WScript.Echo "[" & i & "] " & voices.Item(i).GetDescription |
| 226 | +Next |
| 227 | +"@ |
| 228 | + $vbsPath = "$PublishDir\sapi-test.vbs" |
| 229 | + $vbs | Out-File $vbsPath -Encoding ASCII |
| 230 | + |
| 231 | + $result = & cscript //nologo $vbsPath 2>&1 |
| 232 | + $result | ForEach-Object { Write-Host " $_" } |
| 233 | + |
| 234 | + $errors = $result | Where-Object { $_ -match "error|Error|cannot create|failed" } |
| 235 | + $hasVoices = $result | Where-Object { $_ -match "voices total" } |
| 236 | + |
| 237 | + # NullRef from our enumerator is OK - it means COM activation worked |
| 238 | + # but SherpaOnnx native libs aren't deployed. Real error = can't create COM object |
| 239 | + if ($errors -and !($result -match "Object reference not set")) { |
| 240 | + throw "SAPI COM test failed" |
| 241 | + } |
| 242 | + |
| 243 | + if ($hasVoices) { |
| 244 | + Write-Host " Voice enumeration via SAPI working!" |
| 245 | + } else { |
| 246 | + Write-Host " COM activated but enumerator returned error (expected without native deps)" |
| 247 | + } |
| 248 | + |
| 249 | + Remove-Item $vbsPath -Force -ErrorAction SilentlyContinue |
| 250 | + } |
| 251 | + |
| 252 | + # ============================================================ |
| 253 | + # STEP 8: SAPI Speak via Azure or local model |
| 254 | + # ============================================================ |
| 255 | + $azureKey = $AzureKey ?? $env:MICROSOFT_TOKEN ?? $env:AZURE_SPEECH_KEY ?? [System.Environment]::GetEnvironmentVariable("MICROSOFT_TOKEN", "User") |
| 256 | + $azureRegion = $AzureRegion ?? $env:MICROSOFT_REGION ?? $env:AZURE_SPEECH_REGION ?? [System.Environment]::GetEnvironmentVariable("MICROSOFT_REGION", "User") ?? "uksouth" |
| 257 | + |
| 258 | + if ($modelExists -or $azureKey) { |
| 259 | + Test-Step "Step 8: SAPI speak test" { |
| 260 | + if ($azureKey -and !$modelExists) { |
| 261 | + Write-Host " Using Azure TTS (key present, no local model)" |
| 262 | + $env:MICROSOFT_TOKEN = $azureKey |
| 263 | + $env:MICROSOFT_REGION = $azureRegion |
| 264 | + $env:AZURE_SPEECH_KEY = $azureKey |
| 265 | + $env:AZURE_SPEECH_REGION = $azureRegion |
| 266 | + } |
| 267 | + |
| 268 | + $voice = New-Object -ComObject "SAPI.SpVoice" |
| 269 | + |
| 270 | + # Try to find and select our voice |
| 271 | + $voices = $voice.GetVoices() |
| 272 | + $selected = $false |
| 273 | + for ($i = 0; $i -lt $voices.Count; $i++) { |
| 274 | + $desc = $voices.Item($i).GetDescription() |
| 275 | + if ($desc -match "NaturalVoice|DotNet|sherpa|Azure|kokoro|piper") { |
| 276 | + $voice.Voice = $voices.Item($i) |
| 277 | + $selected = $true |
| 278 | + Write-Host " Selected voice: $desc" |
| 279 | + break |
| 280 | + } |
| 281 | + } |
| 282 | + |
| 283 | + if (!$selected) { |
| 284 | + Write-Host " Adapter voice not found in SAPI - using default" |
| 285 | + } |
| 286 | + |
| 287 | + # Set output to WAV file instead of speakers |
| 288 | + $outFile = "$PublishDir\sapi-test-output.wav" |
| 289 | + $fstream = New-Object -ComObject "SAPI.SpFileStream" |
| 290 | + $fstream.Open($outFile, 3, 0) # SSFMCreateForWrite = 3 |
| 291 | + $voice.AudioOutputStream = $fstream |
| 292 | + |
| 293 | + $voice.Speak("Testing voice synthesis from Natural Voice SAPI Adapter.", 0) | Out-Null |
| 294 | + $fstream.Close() |
| 295 | + |
| 296 | + if (Test-Path $outFile) { |
| 297 | + $size = (Get-Item $outFile).Length |
| 298 | + Write-Host " Output WAV: $size bytes" |
| 299 | + if ($size -lt 100) { throw "Output WAV too small" } |
| 300 | + } else { |
| 301 | + throw "No output WAV file created" |
| 302 | + } |
| 303 | + } |
| 304 | + } else { |
| 305 | + Test-StepSkip "Step 8: SAPI speak test" "No SherpaOnnx models or Azure credentials. Set MICROSOFT_TOKEN + MICROSOFT_REGION env vars or pass -AzureKey" |
| 306 | + } |
| 307 | + |
| 308 | + # ============================================================ |
| 309 | + # Cleanup: Unregister COM |
| 310 | + # ============================================================ |
| 311 | + Test-Step "Cleanup: Unregister COM server" { |
| 312 | + $comhost = Join-Path $PublishDir "NaturalVoiceSAPIAdapter.Net.comhost.dll" |
| 313 | + & "$env:windir\System32\regsvr32.exe" /u /s $comhost 2>$null |
| 314 | + |
| 315 | + # Also clean up managed registration fallback if used |
| 316 | + try { |
| 317 | + Add-Type -Path (Join-Path $PublishDir "NaturalVoiceSAPIAdapter.Net.dll") -ErrorAction SilentlyContinue |
| 318 | + [NaturalVoiceSAPIAdapter.ComRegistration]::Unregister([NaturalVoiceSAPIAdapter.TTSEngine]) |
| 319 | + } catch {} |
| 320 | + |
| 321 | + Write-Host " Unregistered" |
| 322 | + } |
| 323 | + } else { |
| 324 | + Write-Host "`n Not running as admin - skipping steps 5-8" -ForegroundColor Yellow |
| 325 | + Write-Host " Re-run as admin for full COM/SAPI tests: elevate && .\test-e2e.ps1 -Full" -ForegroundColor Yellow |
| 326 | + $script:Skip += 4 |
| 327 | + } |
| 328 | +} else { |
| 329 | + Test-StepSkip "Steps 5-8 (COM/SAPI)" "Run with -Full flag for COM registration and SAPI tests" |
| 330 | +} |
| 331 | + |
| 332 | +# ============================================================ |
| 333 | +# Summary |
| 334 | +# ============================================================ |
| 335 | +Write-Host "`n========================================" -ForegroundColor White |
| 336 | +Write-Host "Results: $Pass passed, $Fail failed, $Skip skipped" -ForegroundColor $(if ($Fail -gt 0) { "Red" } else { "Green" }) |
| 337 | +Write-Host "========================================" -ForegroundColor White |
| 338 | + |
| 339 | +if ($Fail -gt 0) { exit 1 } |
0 commit comments