mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-22 15:23:23 +00:00
83 lines
4.2 KiB
Python
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()
|