Files
HaloKeymind/scripts/generate_gps_voice.ps1
T

92 lines
4.5 KiB
PowerShell

# Generate the two fixed phrases with Windows' local speech synthesizer.
# Requires ffmpeg and Python; outputs WAV previews plus the checked-in header.
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[ValidateNotNullOrEmpty()]
[string]$OutputDirectory = 'out/gps-voice',
[ValidateNotNullOrEmpty()]
[string]$OutputHeader = 'src/helpers/ui/GpsVoiceData.h',
[ValidateNotNullOrEmpty()]
[string]$Voice = 'Microsoft Zira Desktop',
[ValidateRange(-10, 10)]
[int]$Rate = -1,
[switch]$Compress = $true,
[ValidateRange(0.0625, 1.0)]
[double]$PeakLimit = 0.12
)
$ErrorActionPreference = 'Stop'
# Require a deliberate header target for acoustic experiments. A normal run
# regenerates the selected Zira / rate -1 / compressed / gain-8 profile.
# Speech is prerecorded and shared by supported boards; no runtime TTS is needed.
# Original Zira: -Voice 'Microsoft Zira Desktop' -Compress:$false -PeakLimit 0.8
# with an explicit -OutputHeader.
if (-not $PSBoundParameters.ContainsKey('OutputHeader') -and
($Voice -ne 'Microsoft Zira Desktop' -or $Rate -ne -1 -or
-not $Compress -or $PeakLimit -ne 0.12)) {
throw 'Voice variants require an explicit -OutputHeader (use an out/gps-voice path for tests).'
}
$gpsDirectory = [IO.Path]::GetFullPath($OutputDirectory)
$gpsHeader = [IO.Path]::GetFullPath($OutputHeader)
if ([IO.Path]::GetExtension($gpsHeader) -ine '.h') {
throw '-OutputHeader must name a .h file.'
}
if (Test-Path -LiteralPath $gpsDirectory -PathType Leaf) {
throw '-OutputDirectory names an existing file.'
}
if (Test-Path -LiteralPath $gpsHeader -PathType Container) {
throw '-OutputHeader names an existing directory.'
}
$gpsEncoder = Join-Path $PSScriptRoot 'generate_gps_voice.py'
if (-not (Test-Path -LiteralPath $gpsEncoder -PathType Leaf)) {
throw "Voice encoder not found: $gpsEncoder"
}
$gpsFilter = 'highpass=f=350,lowpass=f=3400,silenceremove=start_periods=1:start_threshold=-45dB,areverse,silenceremove=start_periods=1:start_threshold=-45dB,areverse'
if ($Compress) {
# Raise quieter consonants without simply clipping the louder vowels.
$gpsFilter += ',acompressor=threshold=0.08:ratio=6:attack=5:release=70:makeup=4:knee=4:detection=rms'
}
$gpsFilter += ',volume=1.4'
if ($Compress -or $PeakLimit -ne 0.8) {
# Limit after resampling so the experimental PCM peak has real headroom.
$gpsFilter += ',aresample=8000'
}
$gpsPeak = $PeakLimit.ToString('0.####', [Globalization.CultureInfo]::InvariantCulture)
$gpsFilter += ",alimiter=limit=${gpsPeak}:level=disabled,apad=pad_dur=0.05"
# For player gains 4 / 6 / 8, begin at -PeakLimit 0.18 / 0.12 / 0.09
# (72% nominal full scale after gain). Check decoded/reconstructed peaks too:
# ADPCM quantization and interpolation can overshoot the source PCM ceiling.
Write-Verbose "Voice: $Voice; rate: $Rate; output directory: $gpsDirectory"
Write-Verbose "Output header: $gpsHeader"
Write-Verbose "FFmpeg filter: $gpsFilter"
if (-not $PSCmdlet.ShouldProcess($gpsHeader, "Synthesize GPS on/off with $Voice and encode previews from $gpsDirectory")) {
return
}
Add-Type -AssemblyName System.Speech
$gpsVoice = New-Object System.Speech.Synthesis.SpeechSynthesizer
try {
$gpsVoices = @($gpsVoice.GetInstalledVoices() | Where-Object Enabled | ForEach-Object { $_.VoiceInfo.Name })
if ($gpsVoices -notcontains $Voice) {
throw "Voice '$Voice' is unavailable. Installed voices: $($gpsVoices -join ', ')"
}
$gpsVoice.SelectVoice($Voice)
$gpsVoice.Rate = $Rate
Get-Command ffmpeg, python -ErrorAction Stop | Out-Null
New-Item -ItemType Directory -Force -Path $gpsDirectory | Out-Null
New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($gpsHeader)) | Out-Null
foreach ($gpsState in @('on','off')) {
$gpsRaw = Join-Path $gpsDirectory "gps-$gpsState-raw.wav"
$gpsPcm = Join-Path $gpsDirectory "gps-$gpsState.wav"
$gpsVoice.SetOutputToWaveFile([IO.Path]::GetFullPath($gpsRaw))
$gpsVoice.Speak("G P S $gpsState")
$gpsVoice.SetOutputToNull()
# Keep enough consonant energy for a small beeper; limit clipping.
& ffmpeg -hide_banner -loglevel error -y -i $gpsRaw -af $gpsFilter -ar 8000 -ac 1 -c:a pcm_s16le $gpsPcm
if ($LASTEXITCODE -ne 0) { throw 'Voice conversion failed' }
}
} finally { $gpsVoice.Dispose() }
& python $gpsEncoder --on (Join-Path $gpsDirectory 'gps-on.wav') --off (Join-Path $gpsDirectory 'gps-off.wav') --output $gpsHeader --voice $Voice
if ($LASTEXITCODE -ne 0) { throw 'Voice encoding failed' }