Add Zira spoken button confirmations and opt-in G726 speech tests

This commit is contained in:
mikecarper committed 2026-10-03 03:03:02 -07:00
1 parent 5833ac2f1f
commit 8519638367
33 files changed
+11977 -30

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
# Reproduce the selected Zira / compressed button recordings for supported
# Full Companions; playback gain is configured by the firmware.
# GPS direction phrases are generated separately by generate_gps_voice.ps1.
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[ValidateNotNullOrEmpty()]
[string]$OutputDirectory = 'out/gps-voice/button-zira',
[ValidateNotNullOrEmpty()]
[string]$OutputHeader = 'src/helpers/ui/ButtonVoiceData.h',
[ValidateNotNullOrEmpty()]
[string]$Voice = 'Microsoft Zira Desktop',
# Reuse original, already selected clips while adding a new catalog entry.
[switch]$OnlyMissing
)
$ErrorActionPreference = 'Stop'
# Diagnostic voices must name a deliberate header target; the default stays
# the selected Zira / rate -1 / compressed / peak-0.12 profile.
if (-not $PSBoundParameters.ContainsKey('OutputHeader') -and
$Voice -ne 'Microsoft Zira Desktop') {
throw 'Voice variants require an explicit -OutputHeader (use an out/gps-voice path for tests).'
}
$buttonDirectory = [IO.Path]::GetFullPath($OutputDirectory)
$buttonHeader = [IO.Path]::GetFullPath($OutputHeader)
if ([IO.Path]::GetExtension($buttonHeader) -ine '.h') {
throw '-OutputHeader must name a .h file.'
}
if (Test-Path -LiteralPath $buttonDirectory -PathType Leaf) {
throw '-OutputDirectory names an existing file.'
}
if (Test-Path -LiteralPath $buttonHeader -PathType Container) {
throw '-OutputHeader names an existing directory.'
}
$buttonEncoder = Join-Path $PSScriptRoot 'generate_button_voice.py'
if (-not (Test-Path -LiteralPath $buttonEncoder -PathType Leaf)) {
throw "Voice encoder not found: $buttonEncoder"
}
if (-not $PSCmdlet.ShouldProcess($buttonHeader, "Synthesize and encode $Voice button confirmations")) {
return
}
$buttonPhrases = [ordered]@{
ready = 'Ready'
notificationCleared = 'Notification cleared'
advertQueued = 'Advert queued'
advertFailed = 'Advert failed'
soundOn = 'Sound on'
soundOff = 'Sound off'
actionFailed = 'Action failed'
usbSetup = 'USB setup'
shuttingDown = 'Shutting down'
restarting = 'Restarting'
alertsSoundVibration = 'Sound and vibration'
alertsSoundOnly = 'Sound only'
alertsVibrationOnly = 'Vibration only'
alertsSilent = 'Silent'
}
$buttonFilter = 'highpass=f=350,lowpass=f=3400,silenceremove=start_periods=1:start_threshold=-45dB,areverse,silenceremove=start_periods=1:start_threshold=-45dB,areverse,acompressor=threshold=0.08:ratio=6:attack=5:release=70:makeup=4:knee=4:detection=rms,volume=1.4,aresample=8000,alimiter=limit=0.12:level=disabled,apad=pad_dur=0.05'
Add-Type -AssemblyName System.Speech
$buttonVoice = New-Object System.Speech.Synthesis.SpeechSynthesizer
try {
$buttonVoices = @($buttonVoice.GetInstalledVoices() | Where-Object Enabled | ForEach-Object { $_.VoiceInfo.Name })
if ($buttonVoices -notcontains $Voice) {
throw "Voice '$Voice' is unavailable. Installed voices: $($buttonVoices -join ', ')"
}
Get-Command ffmpeg, python -ErrorAction Stop | Out-Null
$buttonVoice.SelectVoice($Voice)
$buttonVoice.Rate = -1
New-Item -ItemType Directory -Force -Path $buttonDirectory | Out-Null
New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($buttonHeader)) | Out-Null
foreach ($buttonName in $buttonPhrases.Keys) {
$buttonRaw = Join-Path $buttonDirectory "$buttonName-raw.wav"
$buttonPcm = Join-Path $buttonDirectory "$buttonName.wav"
if ($OnlyMissing -and (Test-Path -LiteralPath $buttonPcm -PathType Leaf)) {
continue
}
$buttonVoice.SetOutputToWaveFile($buttonRaw)
$buttonVoice.Speak($buttonPhrases[$buttonName])
$buttonVoice.SetOutputToNull()
& ffmpeg -hide_banner -loglevel error -y -i $buttonRaw -af $buttonFilter -ar 8000 -ac 1 -c:a pcm_s16le $buttonPcm
if ($LASTEXITCODE -ne 0) { throw "Voice conversion failed: $buttonName" }
}
} finally { $buttonVoice.Dispose() }
& python $buttonEncoder --directory $buttonDirectory --output $buttonHeader --voice $Voice
if ($LASTEXITCODE -ne 0) { throw 'Button voice encoding failed' }
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Encode the fixed button-confirmation WAV catalog as independent IMA clips."""
import argparse
from pathlib import Path
from generate_gps_voice import encode, read
PHRASES = (
('ready', 'Ready'),
('notificationCleared', 'Notification cleared'),
('advertQueued', 'Advert queued'),
('advertFailed', 'Advert failed'),
('soundOn', 'Sound on'),
('soundOff', 'Sound off'),
('actionFailed', 'Action failed'),
('usbSetup', 'USB setup'),
('shuttingDown', 'Shutting down'),
('restarting', 'Restarting'),
('alertsSoundVibration', 'Sound and vibration'),
('alertsSoundOnly', 'Sound only'),
('alertsVibrationOnly', 'Vibration only'),
('alertsSilent', 'Silent'),
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--directory', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--voice', help='Speech voice used to synthesize the source WAVs')
args = parser.parse_args()
if args.voice is not None and (not args.voice.strip() or '\n' in args.voice or '\r' in args.voice):
parser.error('--voice must be a nonempty single-line label')
lines = [
'// Generated by scripts/generate_button_voice.py. 8 kHz IMA ADPCM.',
'// Fixed spoken confirmations; no runtime speech engine.',
'#pragma once', '#include <helpers/ui/GpsVoice.h>',
'namespace mesh { namespace audio {',
]
if args.voice is not None:
lines.insert(2, f'// Voice: {args.voice}')
total = 0
for name, phrase in PHRASES:
samples = read(args.directory / (name + '.wav'))
# Shutdown drains audio nonblockingly for at most five seconds.
if not samples or len(samples) > 32000:
raise ValueError(f'{phrase}: expected a nonempty clip under four seconds')
encoded = encode(samples)
total += len(encoded)
if name == 'alertsSoundVibration':
# Only buzzer+vibration devices need the additional mode catalog.
lines.append('#ifdef HAS_DRV2605')
lines.extend([f'// "{phrase}"', f'static const uint8_t {name}Data[] = {{'])
for offset in range(0, len(encoded), 20):
lines.append(' ' + ','.join(f'0x{b:02x}' for b in encoded[offset:offset + 20]) + ',')
lines.extend(['};', f'static constexpr VoiceClip {name}Clip = {{',
f' {name}Data, sizeof({name}Data), {len(samples)}, 8000', '};'])
print(f'{phrase}: {len(samples) / 8000:.3f}s, {len(encoded)} bytes')
lines.append('#endif // HAS_DRV2605')
lines.append('}} // namespace mesh::audio')
args.output.write_text('\n'.join(lines) + '\n', encoding='utf-8')
print(f'Button confirmations: {total} audio bytes')
if __name__ == '__main__':
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Generate an opt-in G.726 speech catalog; never overwrite the IMA catalog.
Requires FFmpeg with the g726 encoder. Source WAVs must be the approved mono
8 kHz/16-bit Zira recordings. Both bitrates are stored in the source header,
but the preprocessor selects exactly one for each hardware test build.
"""
import argparse
import struct
import subprocess
from pathlib import Path
from generate_button_voice import PHRASES
from generate_gps_voice import read
def encode(samples, bitrate, ffmpeg="ffmpeg"):
if bitrate not in (16, 24):
raise ValueError("G.726 bitrate must be 16 or 24 kbps")
pcm = struct.pack("<" + "h" * len(samples), *samples)
result = subprocess.run([
ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "s16le",
"-ar", "8000", "-ac", "1", "-i", "pipe:0", "-c:a", "g726",
"-b:a", str(bitrate * 1000), "-f", "g726", "pipe:1",
], input=pcm, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
expected = (len(samples) * (bitrate // 8) + 7) // 8
if len(result.stdout) != expected:
raise ValueError(f"G.726-{bitrate}: expected {expected} bytes, got {len(result.stdout)}")
return result.stdout
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--directory", type=Path, required=True, help="Button WAV catalog")
parser.add_argument("--on", type=Path, required=True, help="GPS-on WAV")
parser.add_argument("--off", type=Path, required=True, help="GPS-off WAV")
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--ffmpeg", default="ffmpeg")
parser.add_argument("--voice", required=True, help="Voice used to synthesize source WAVs")
args = parser.parse_args()
if not args.voice.strip() or "\n" in args.voice or "\r" in args.voice:
parser.error("--voice must be a nonempty single-line label")
if args.output.name.casefold() in ("buttonvoicedata.h", "gpsvoicedata.h"):
parser.error("G.726 experiments must not overwrite production IMA headers")
catalog = [(name, phrase, args.directory / (name + ".wav")) for name, phrase in PHRASES]
catalog += [("gpsOn", "GPS on", args.on), ("gpsOff", "GPS off", args.off)]
samples = [(name, phrase, read(path)) for name, phrase, path in catalog]
for name, _, pcm in samples:
if not pcm or len(pcm) > 32000:
raise ValueError(f"{name}: expected a nonempty clip under four seconds")
lines = [
"// Generated by scripts/generate_g726_voice.py. EXPERIMENT ONLY.",
f"// Voice: {args.voice}. 8 kHz mono, MSB-first raw G.726, fresh state per clip.",
"// Original sample counts exclude the unused bits in the final byte.",
"#pragma once", "#include <helpers/ui/GpsVoice.h>",
"#if !defined(MESH_GPS_VOICE_G726_BITRATE)",
'#error "G.726 catalog requires an explicitly selected experimental bitrate"',
"#endif", "namespace mesh { namespace audio {",
]
for bitrate in (16, 24):
lines.append(f"#if MESH_GPS_VOICE_G726_BITRATE == {bitrate}")
total = 0
for name, phrase, pcm in samples:
mode = name.startswith("alerts")
if mode:
lines.append("#ifdef HAS_DRV2605")
data = encode(pcm, bitrate, args.ffmpeg)
total += len(data)
lines += [f'// "{phrase}"', f"static const uint8_t {name}Data[] = {{"]
for offset in range(0, len(data), 20):
lines.append(" " + ",".join(f"0x{b:02x}" for b in data[offset:offset + 20]) + ",")
lines += ["};", f"static constexpr VoiceClip {name}Clip = {{",
f" {name}Data, sizeof({name}Data), {len(pcm)}, 8000, VoiceCodec::G726{bitrate}", "};"]
if mode:
lines.append("#endif // HAS_DRV2605")
lines.append(f"#endif // {bitrate} kbps")
print(f"G.726-{bitrate}: {total} bytes across all 16 clips")
lines.append("}} // namespace mesh::audio")
args.output.write_text("\n".join(lines) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
# 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' }
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Encode 8/16 kHz mono 16-bit WAV speech as independent IMA ADPCM clips."""
import argparse
from pathlib import Path
import struct
import wave
STEPS = (7,8,9,10,11,12,13,14,16,17,19,21,23,25,28,31,34,37,41,45,50,55,
60,66,73,80,88,97,107,118,130,143,157,173,190,209,230,253,279,307,
337,371,408,449,494,544,598,658,724,796,876,963,1060,1166,1282,1411,
1552,1707,1878,2066,2272,2499,2749,3024,3327,3660,4026,4428,4871,
5358,5894,6484,7132,7845,8630,9493,10442,11487,12635,13899,15289,
16818,18500,20350,22385,24623,27086,29794,32767)
ADJUST = (-1,-1,-1,-1,2,4,6,8)
def encode(samples):
predictor = index = 0
codes = []
for sample in samples:
step = STEPS[index]
difference = sample - predictor
code = 8 if difference < 0 else 0
difference = abs(difference)
delta = step >> 3
for bit, part in ((4,step),(2,step >> 1),(1,step >> 2)):
if difference >= part:
code |= bit
difference -= part
delta += part
predictor = max(-32768,min(32767,predictor + (-delta if code & 8 else delta)))
index = max(0,min(88,index + ADJUST[code & 7]))
codes.append(code)
if len(codes) & 1:
codes.append(0)
return bytes(a | b << 4 for a,b in zip(codes[::2], codes[1::2]))
def read(path, rate=8000):
with wave.open(str(path),'rb') as wav:
if (wav.getnchannels(),wav.getsampwidth(),wav.getframerate()) != (1,2,rate):
raise ValueError(f'Expected mono 16-bit PCM at {rate} Hz')
data = wav.readframes(wav.getnframes())
return struct.unpack('<' + 'h' * (len(data)//2),data)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--on',type=Path,required=True)
parser.add_argument('--off',type=Path,required=True)
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--rate',type=int,choices=(8000,16000),default=8000)
parser.add_argument('--voice', help='Speech voice used to synthesize the source WAVs')
args = parser.parse_args()
if args.voice is not None and (not args.voice.strip() or '\n' in args.voice or '\r' in args.voice):
parser.error('--voice must be a nonempty single-line label')
lines = [f'// Generated by scripts/generate_gps_voice.py. {args.rate/1000:g} kHz IMA ADPCM.',
'// Phrases: "GPS on", "GPS off". No runtime speech engine.',
'#pragma once','#include <helpers/ui/GpsVoice.h>',
'namespace mesh { namespace audio {']
if args.voice is not None:
lines.insert(2, f'// Voice: {args.voice}')
for name,path in (('On',args.on),('Off',args.off)):
samples = read(path,args.rate)
encoded = encode(samples)
lines.append(f'static const uint8_t gps{name}Data[] = {{')
for i in range(0,len(encoded),20):
lines.append(' '+','.join(f'0x{b:02x}' for b in encoded[i:i+20])+',')
lines.extend(['};',f'static constexpr VoiceClip gps{name}Clip = {{',
f' gps{name}Data, sizeof(gps{name}Data), {len(samples)}, {args.rate}', '};'])
print(f'GPS {name.lower()}: {len(samples)/args.rate:.3f}s, {len(encoded)} bytes')
lines.append('}} // namespace mesh::audio')
args.output.write_text('\n'.join(lines)+'\n',encoding='utf-8')
if __name__ == '__main__':
main()