mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-22 11:12:26 +00:00
103 lines
4.6 KiB
Python
103 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Encode GPS phrases, sharing their identical encoded prefix without loss."""
|
|
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 shared_prefix(on, off):
|
|
"""Return three spans whose concatenations exactly restore both streams.
|
|
|
|
A split is byte-based, not a word/codec boundary. Playback must preserve
|
|
adaptive codec state and bit position at the seam. Leave a nonempty tail
|
|
for both phrases, including identical or very short fixture recordings.
|
|
"""
|
|
limit = min(len(on), len(off)) - 1
|
|
split = 0
|
|
while split < limit and on[split] == off[split]:
|
|
split += 1
|
|
return on[:split], on[split:], off[split:]
|
|
|
|
def emit_array(lines, name, data):
|
|
lines.append(f'static const uint8_t {name}Data[] = {{')
|
|
for i in range(0,len(data),20):
|
|
lines.append(' '+','.join(f'0x{b:02x}' for b in data[i:i+20])+',')
|
|
lines.append('};')
|
|
|
|
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}')
|
|
phrases = [read(path,args.rate) for path in (args.on,args.off)]
|
|
if any(not samples or len(samples) > args.rate * 4 for samples in phrases):
|
|
raise ValueError('GPS phrases must be nonempty clips under four seconds')
|
|
encoded = [encode(samples) for samples in phrases]
|
|
prefix, on, off = shared_prefix(*encoded)
|
|
if len(prefix) < 64:
|
|
prefix, on, off = b'', *encoded
|
|
if prefix:
|
|
lines.append('// Three stored spans; preserve decoder state across the shared prefix.')
|
|
emit_array(lines, 'gps', prefix)
|
|
for name,samples,data in zip(('On','Off'),phrases,(on,off)):
|
|
emit_array(lines, 'gps'+name, data)
|
|
fields = f' gps{name}Data, sizeof(gps{name}Data), {len(samples)}, {args.rate}'
|
|
if prefix:
|
|
fields += ', gpsData, sizeof(gpsData)'
|
|
lines.extend([f'static constexpr VoiceClip gps{name}Clip = {{',
|
|
fields, '};'])
|
|
print(f'GPS {name.lower()}: {len(samples)/args.rate:.3f}s, {len(data)} tail bytes')
|
|
print(f'GPS shared prefix: {len(prefix)} bytes; audio saved: {len(prefix)} bytes')
|
|
lines.append('}} // namespace mesh::audio')
|
|
args.output.write_text('\n'.join(lines)+'\n',encoding='utf-8')
|
|
|
|
if __name__ == '__main__':
|
|
main()
|