Add Zira spoken button confirmations and opt-in G726 speech tests
This commit is contained in:
1 parent
5833ac2f1f
commit
8519638367
33 files changed
+11977
-30
No files matched your search
@@ -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' }
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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' }
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user