Files
HaloKeymind/scripts/generate_g726_voice.py
T

83 lines
4.2 KiB
Python

#!/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, emit_catalog, emit_clip
from generate_gps_voice import read, shared_prefix, emit_array
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}")
pcm_by_name = {name: pcm for name, _, pcm in samples}
encoded = {name: encode(pcm, bitrate, args.ffmpeg) for name, _, pcm in samples}
saved = emit_catalog(lines, pcm_by_name, encoded, codec=f'G726{bitrate}')
prefix, on, off = shared_prefix(encoded['gpsOn'], encoded['gpsOff'])
if len(prefix) < 64:
prefix, on, off = b'', encoded['gpsOn'], encoded['gpsOff']
if prefix:
lines.append('// GPS spans form one continuous stream, including cross-byte codes.')
emit_array(lines, 'gps', prefix)
for name, data in (('gpsOn', on), ('gpsOff', off)):
emit_clip(lines, name, data, len(pcm_by_name[name]),
prefix='gps' if prefix else None, codec=f'G726{bitrate}')
lines.append(f"#endif // {bitrate} kbps")
total = sum(map(len, encoded.values())) - saved - len(prefix)
print(f"G.726-{bitrate}: {total} bytes across all 16 phrases")
lines.append("}} // namespace mesh::audio")
args.output.write_text("\n".join(lines) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()