Files
MeshCore/test/test_g726_voice_generator.py
T

199 lines
13 KiB
Python

"""Validate the experimental G.726 catalog without FFmpeg or speech synthesis."""
from pathlib import Path
import hashlib
import io
import importlib.util
import re
import struct
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
from test_button_voice_generator import CATALOG, ZIRA_RECORDINGS, select_vibration
from test_gps_voice_generator import ZIRA_GPS_RECORDINGS
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT/'scripts/generate_g726_voice.py'
sys.path.insert(0,str(ROOT/'scripts'))
try:
spec=importlib.util.spec_from_file_location('g726_generator_under_test',SCRIPT)
generator=importlib.util.module_from_spec(spec)
spec.loader.exec_module(generator)
finally:
sys.path.remove(str(ROOT/'scripts'))
# Independent FFmpeg reference payloads from approved Zira WAVs; fixed here
# rather than inferred from the generated header being tested. Tuple order is
# 16 kbps then 24 kbps, always raw MSB-first g726, not g726le.
GOLDENS = {
'actionFailed': ('33663298be60ec0e2add5ebde561e69e503f222a0ab8c37323498eb1d1714365', '7e5e12f8372c473055bd07e169b3438c6f816dd68f0cd1b3ef09048f6fe51afc'),
'advertFailed': ('135d7e4ca80bcc34c02f51d72220aa17f4fc75f0d23fdac3f7eb46c53b2ecdc1', '92ce6a28b2daca7754f2b845320da0520fab447fdff46ac89ff59d283b5f28a6'),
'advertQueued': ('6f0820ccb7823100f9ad0987f45c82db1b80eb2ab3cbefdd5ebff223e59bf94b', '776a6c07f48cae3f9038f811585d1d99da35f2c203333556ad4dd9b72b9e30da'),
'alertsSilent': ('3392d2847dfcbdc8a3851653a777e32eedbd748b0588fbb1e8af8bbc75f78516', 'f01749532c1edba71079594f58dca6888cc37b9ac3cb9ec9ec12ffd813834a69'),
'alertsSoundOnly': ('1488c87772a6868b621eed3144ec46e2bda551265919cd5dd881f2640628096a', '801e8c93c52d6b219557aaff305ccdbd3c59caa5dc4bce4a1f41cc90c08b5da9'),
'alertsSoundVibration': ('90506e3b6d2626021f34c93337bb4460b77360547dbd51b821c24b23e84c2634', '4255e55a8e6be96ac8da1e8b36476896f758b27db3f88bef045f0af180f7edf8'),
'alertsVibrationOnly': ('b4e62f14ddb4c1f6783018feeae3a732bd97e73275659d4106b4ad4eca83ac70', '9ea16e3f209a544d86bab44bc36bb811d79630134fa1ccc455a68ff06edde980'),
'notificationCleared': ('734d1eaa542201c3138962647672f076a317bef044bf25ead2c4648797ed6550', '867ef876f2804b8d2767afddd9f7d922c129e6f47dfbe08d4ed5966ae6d3d24b'),
'ready': ('2a266c9f56176376eb2322a2b72e4df868a35926f4b520e13acf337e792534e3', '8eaa38100aa5a482ad72504ce40995979de45843794ffd198b2d1685089e17be'),
'restarting': ('9c204820af9bb40de30edac13bea43249884ba82753b9f6706b311abd9a93786', '2a6f1c6fcbaa26a12c9381f5ace6c7cda68f89ed7651dc506fff27d8a41aeae6'),
'shuttingDown': ('cef5b599fa81aef97459becae5e2b8e85287f153a7b5d6c5467959ee8facc130', '11fde2f8561194d823252cf957f044dc24900ebdb0debb0dae83d043b81ee627'),
'soundOff': ('20a8242182bead0359ea4920c99963ce324285a4e1d28bfcb3d8324d10d7e8c4', 'ccbf34a368af7d89892c96b8b8b59361e7671e717354d41c3c365c011a5a1261'),
'soundOn': ('e346e20ea7e69b5f40f39235d57d9e5805353a4a7abbf52800f46a9670d25fd1', '4a5f531ce35dafdd1c7735bb3d67018bae73c724cce1bf8fc0dba10a15b4d201'),
'usbSetup': ('fe99f1b81aff17db4afd6c1b9f21e70a78e983bdf1b61de99bb5ff90f01cda75', 'a714e239ebc3c2c373f246a627334db974b6d197e096e1025d10748e01f40498'),
'gpsOff': ('2d85181600ccd769a2be90393244cbd31ca8bdc3f0a919c8f24be6ed6b1792e2', 'c7ca2f32062ce0f16531d1e11c42faba2e3486b609a96c5160d0ab7d87917587'),
'gpsOn': ('8d198c025d6bc123e59d890a718835f27c75bf404f5c5288bbf08780e514aff0', 'fc44dccf32f0fb94a4d3b8b4b114eea19c5210e515d3b300fce847e5fc788b4a'),
}
class EncodeTests(unittest.TestCase):
def test_encoder_uses_exact_msb_first_ffmpeg_command_pcm_and_ceil_length(self):
for bitrate in (16,24):
for samples in ([0,-1,1], [32767,-32768,0,127,-128,1024,-1024,7,-7]):
with self.subTest(bitrate=bitrate,samples=len(samples)):
size=(len(samples)*(bitrate//8)+7)//8
payload=bytes(range(size))
with mock.patch.object(generator.subprocess,'run',
return_value=SimpleNamespace(stdout=payload)) as run:
self.assertEqual(generator.encode(samples,bitrate,'chosen-ffmpeg'),payload)
self.assertEqual(run.call_args.args[0],[
'chosen-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'])
self.assertEqual(run.call_args.kwargs['input'],
struct.pack('<'+'h'*len(samples),*samples))
self.assertTrue(run.call_args.kwargs['check'])
self.assertEqual(run.call_args.kwargs['stdout'],subprocess.PIPE)
self.assertEqual(run.call_args.kwargs['stderr'],subprocess.PIPE)
def test_bad_bitrates_are_rejected_before_spawning_ffmpeg(self):
for bitrate in (-1,0,8,32,40,None,'16'):
with self.subTest(bitrate=bitrate), mock.patch.object(generator.subprocess,'run') as run:
with self.assertRaisesRegex(ValueError,'bitrate must be 16 or 24'):
generator.encode([0,1,-1],bitrate)
run.assert_not_called()
def test_short_long_ffmpeg_output_and_failed_process_are_rejected(self):
for bitrate in (16,24):
expected=(5*(bitrate//8)+7)//8
for length in (expected-1,expected+1):
with self.subTest(bitrate=bitrate,length=length):
with mock.patch.object(generator.subprocess,'run',
return_value=SimpleNamespace(stdout=bytes(length))):
with self.assertRaisesRegex(ValueError,f'expected {expected} bytes, got {length}'):
generator.encode([0]*5,bitrate)
with mock.patch.object(generator.subprocess,'run',
side_effect=subprocess.CalledProcessError(1,['ffmpeg'])):
with self.assertRaises(subprocess.CalledProcessError):generator.encode([0],16)
class CatalogTests(unittest.TestCase):
def test_mock_generation_emits_both_bitrate_catalogs_and_exact_source_lengths(self):
names=tuple(name for name,_ in CATALOG)+('gpsOn','gpsOff')
source_samples={name:[index]*((index+1)*3) for index,name in enumerate(names)}
with tempfile.TemporaryDirectory() as temporary:
directory=Path(temporary);output=directory/'generated.h'
arguments=self.argv(directory,output,'Microsoft Zira Desktop')
def read(path):
name={'missing-on.wav':'gpsOn','missing-off.wav':'gpsOff'}.get(path.name,path.stem)
return source_samples[name]
def encode(samples,bitrate,ffmpeg):
self.assertEqual(ffmpeg,'ffmpeg')
return bytes([bitrate])*((len(samples)*(bitrate//8)+7)//8)
with mock.patch.object(sys,'argv',arguments), mock.patch.object(generator,'read',side_effect=read), \
mock.patch.object(generator,'encode',side_effect=encode) as encoder, \
mock.patch.object(sys,'stdout',io.StringIO()):
generator.main()
self.assertEqual(encoder.call_count,32)
source=output.read_text(encoding='utf-8')
for bitrate in (16,24):
body=source.split(f'#if MESH_GPS_VOICE_G726_BITRATE == {bitrate}\n',1)[1]
body=body.split(f'#endif // {bitrate} kbps',1)[0]
for name in names:
count=len(source_samples[name])
self.assertIn(f'{name}Data, sizeof({name}Data), {count}, 8000, VoiceCodec::G726{bitrate}',body)
plain = select_vibration(body, False)
for name in names:
self.assertEqual(f'{name}Clip' in plain, not name.startswith('alerts'))
def test_invalid_source_lengths_preserve_existing_experimental_header(self):
for count in (0,32001):
with self.subTest(samples=count), tempfile.TemporaryDirectory() as temporary:
directory=Path(temporary);output=directory/'generated.h'
output.write_bytes(b'existing experiment\n')
def read(path):return [0]*count if path.name=='missing-off.wav' else [0,1,-1]
with mock.patch.object(sys,'argv',self.argv(directory,output,'Microsoft Zira Desktop')), \
mock.patch.object(generator,'read',side_effect=read), \
mock.patch.object(generator,'encode') as encoder:
with self.assertRaisesRegex(ValueError,'nonempty clip under four seconds'):
generator.main()
encoder.assert_not_called()
self.assertEqual(output.read_bytes(),b'existing experiment\n')
def test_generated_16_and_24_catalogs_match_independent_zira_reference_hashes_and_lengths(self):
source=(ROOT/'src/helpers/ui/G726VoiceData.h').read_text(encoding='utf-8')
self.assertIn('// Voice: Microsoft Zira Desktop. 8 kHz mono, MSB-first raw G.726',source)
self.assertIn('EXPERIMENT ONLY',source)
self.assertIn('#if !defined(MESH_GPS_VOICE_G726_BITRATE)',source)
names=tuple(name for name,_ in CATALOG)+('gpsOn','gpsOff')
samples={name:value[1] for name,value in {**ZIRA_RECORDINGS,**ZIRA_GPS_RECORDINGS}.items()}
self.assertEqual(set(names),set(GOLDENS))
for index,bitrate in enumerate((16,24)):
with self.subTest(bitrate=bitrate):
body=source.split(f'#if MESH_GPS_VOICE_G726_BITRATE == {bitrate}\n',1)[1]
body=body.split(f'#endif // {bitrate} kbps',1)[0]
selected = select_vibration(body)
spans={name:bytes(int(value,16) for value in re.findall(r'0x([0-9a-fA-F]{2})',data))
for name,data in re.findall(r'static const uint8_t (\w+)Data\[\] = \{(.*?)\};',selected,re.S)}
arrays, clips = {}, {}
for name,data,size,count,rate,codec,prefix,prefix_size in re.findall(
r'static constexpr VoiceClip (\w+)Clip = \{\s*'
r'(\w+)Data, sizeof\((\w+)Data\), (\d+), (\d+), VoiceCodec::G726(\d+)'
r'(?:, (\w+)Data, sizeof\((\w+)Data\))?\s*\};',selected):
self.assertEqual(data,size)
self.assertEqual(prefix,prefix_size)
arrays[name]=(spans[prefix] if prefix else b'')+spans[data]
clips[name]=(data,size,int(count),int(rate),int(codec))
self.assertEqual(tuple(arrays),names)
self.assertEqual(tuple(clips),names)
for name in names:
self.assertEqual(clips[name],(name,name,samples[name],8000,bitrate))
self.assertEqual(len(arrays[name]),(samples[name]*(bitrate//8)+7)//8)
self.assertEqual(hashlib.sha256(arrays[name]).hexdigest(),GOLDENS[name][index],name)
plain=select_vibration(body,False)
self.assertNotIn('soundPrefixData',plain)
for name in names:
self.assertEqual(f'{name}Clip' in plain,not name.startswith('alerts'))
def test_cli_refuses_production_filenames_and_invalid_voice_without_touching_existing_files(self):
with tempfile.TemporaryDirectory() as temporary:
directory=Path(temporary)
for name in ('ButtonVoiceData.h','GpsVoiceData.h','buttonvoicedata.h','GPSVOICEDATA.H'):
with self.subTest(name=name):
target=directory/name;target.write_bytes(b'existing selected IMA audio\n')
result=self.cli(directory,target,'Microsoft Zira Desktop')
self.assertNotEqual(result.returncode,0)
self.assertIn('must not overwrite production IMA headers',result.stderr)
self.assertEqual(target.read_bytes(),b'existing selected IMA audio\n')
for voice in ('',' ','name\nsecondline','name\rsecondline'):
with self.subTest(voice=voice):
target=directory/'experimental.h';target.write_bytes(b'existing experiment\n')
result=self.cli(directory,target,voice)
self.assertNotEqual(result.returncode,0)
self.assertIn('nonempty single-line label',result.stderr)
self.assertEqual(target.read_bytes(),b'existing experiment\n')
def cli(self,directory,output,voice):
return subprocess.run([sys.executable,*self.argv(directory,output,voice)],
capture_output=True,text=True,timeout=30)
def argv(self,directory,output,voice):
return [str(SCRIPT),'--directory',str(directory/'missing-catalog'),
'--on',str(directory/'missing-on.wav'),'--off',str(directory/'missing-off.wav'),
'--output',str(output),'--voice',voice]
if __name__=='__main__':unittest.main()