Files
MeshCore/test/test_g726_voice.py

328 lines
16 KiB
Python

"""Exercise opt-in G.726 speech against independent FFmpeg MSB-packed vectors."""
from pathlib import Path
import hashlib
import json
import os
import shutil
import struct
import subprocess
import tempfile
import unittest
import test_gps_voice_buttons as shared
from test_button_voice_generator import ZIRA_RECORDINGS
from test_gps_voice_generator import ZIRA_GPS_RECORDINGS
ROOT = Path(__file__).resolve().parents[1]
CC = shutil.which('gcc')
CXX = shutil.which('g++')
DECODER = ROOT / 'src/helpers/ui/g726/g726_decoder.c'
SANITIZERS = (['-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-omit-frame-pointer', '-fno-pie', '-no-pie']
if os.environ.get('MESH_G726_TEST_SANITIZERS') == '1' else [])
# FFmpeg 7.1 g726 encoder/decoder, 8 kHz mono, continuous MSB-first packing.
# Input: 149 samples of int(23000*sin(i*.43)+7000*sin(i*1.97)). The reference
# PCM below is pinned, not computed with the decoder being tested. Odd sample
# counts exercise both cross-byte extraction and ignored final padding bits.
VECTORS = {
16: (
'1555aaae557fafd05c2fa3d41cebf7170be8f4133aedc41ffaf1d70ee83113caf9c402fa3d00',
(12,60,68,80,100,120,144,192,-152,-260,-460,-808,-1348,-2240,-1496,-3228,
2668,6388,11088,18032,28296,3608,-2328,-5448,-17708,-30576,-21068,-16732,
-13524,7960,9392,10368,19852,31136,7272,9872,9056,-11444,-11928,-10604,
-19392,-31324,-7528,-9496,-9344,11660,29504,21868,17484,28932,6588,6040,
-2032,-15800,-30028,-23620,-19428,-15560,8012,1476,7584,18856,30580,8036,
11692,10804,-11724,-14448,-12284,-20856,32136,-8856,-9784,-9688,12472,
15444,12360,21112,18280,4004,3612,-1752,-11740,-20788,-16104,-24696,
-20612,7188,-204,8240,23980,20804,14996,23660,8108,-3212,-8576,-10660,
-18848,-25496,-18976,-15376,-5852,10232,7664,20152,-32364,13088,10528,
10836,-900,-16984,-15196,-22620,28652,-15884,1424,-152,8760,23344,17788,
21364,20676,8416,-2200,-2692,-13772,-24704,-19472,-14280,-21680,4836,
4692,9024,25744,25352,17892,15096,11768,-7900,-14896,-14800,-21052,
-29392,-11948,-6336,-6580,11280,18152),
),
24: (
'edb6db92493e6d25ef976a976b93679bfcd7677f2fb972d7467d37a512cf3a6d39a9f2bd367d4aed72adf29d93e96e65f6afdae8dda5f732',
(0,60,76,92,108,132,180,252,-272,-476,-908,-1840,-3640,-7076,-1780,-3756,
6724,16012,19764,21560,22432,8192,-8500,-3184,-18772,-24144,-17696,
-14340,-18324,4024,13468,6724,19724,25136,11348,10740,9836,-5588,
-19216,-9292,-28780,-23492,-10576,-4544,-6324,13428,24408,12108,27792,
23912,2564,-912,-1384,-18568,-27700,-13868,-18952,-17316,3560,5296,
7024,18520,25224,12844,15316,13484,-10176,-10292,-11676,-26980,-26004,
-12020,-16696,-4148,14284,17648,15116,22948,21032,8440,5108,-2084,
-17480,-21536,-18780,-25992,-16528,2392,-3592,9136,29948,21828,17336,
23512,13388,-9504,-7892,-12828,-27144,-19100,-13100,-17900,-5200,
11896,11460,20468,30592,19264,10800,12696,-3676,-18696,-14372,-20972,
-30060,-11752,-3936,-5604,8180,25072,17120,19576,26832,9028,-3872,
-1548,-15656,-28576,-16560,-15880,-21400,204,9884,6276,17700,27396,
14016,12568,13016,-5332,-15028,-13156,-20244,-25892,-13184,-9964,
-9684,11280,23016),
),
}
PREAMBLE = r'''
#include <cassert>
#include <cstdint>
#include <cstring>
#include <initializer_list>
#include <limits>
#include <vector>
#include <helpers/ui/GpsVoice.h>
#define PIN_BUZZER 25
#include <helpers/ui/nrf52/GpsVoicePlayer.h>
uint32_t now_ms=0;
using namespace mesh::audio;
'''
DECODE_MAIN = r'''
int main(){
constexpr auto codec=VoiceCodec::G726@RATE@;
constexpr unsigned bits=@BITS@;
const uint8_t data[]={@DATA@};
const int16_t expected[]={@PCM@};
constexpr unsigned count=sizeof(expected)/sizeof(expected[0]);
const VoiceClip full{data,sizeof(data),count,8000,codec};
assert(full.valid()&&full.bits()==bits);
assert(full.requiredBytes(count)==sizeof(data));
VoiceDecoder decoder;
auto check=[&](VoiceClip clip,unsigned samples){
decoder.begin(clip);assert(decoder.position()==0);
for(unsigned i=0;i<samples;++i){assert(!decoder.done());assert(decoder.next()==expected[i]);}
assert(decoder.done()&&decoder.position()==samples);
for(unsigned i=0;i<8;++i)assert(decoder.next()==0&&decoder.position()==samples);
};
// Each clip resets adaptive state, including after partial playback.
for(unsigned repetition=0;repetition<3;++repetition){
check(full,count);decoder.begin(full);decoder.next();check(full,count);
}
for(unsigned n:{1u,2u,3u,4u,5u,7u,8u,9u,31u,32u,33u,63u,64u,65u,count}){
VoiceClip short_clip{data,full.requiredBytes(n),n,8000,codec};
assert(short_clip.valid());check(short_clip,n);
}
std::vector<uint8_t> padding(data,data+sizeof(data));
const unsigned unused=(8-(count*bits)%8)%8;
assert(unused);padding.back()^=(1u<<unused)-1;
check({padding.data(),padding.size(),count,8000,codec},count);
// Truncated metadata may decode only complete codes; it never reads padding
// or a second byte beyond the declared span when a 3-bit code straddles it.
for(size_t bytes=0;bytes<sizeof(data);++bytes){
std::vector<uint8_t> bounded(data,data+bytes);
VoiceClip short_clip{bounded.data(),bytes,count,8000,codec};
assert(!short_clip.valid());check(short_clip,static_cast<unsigned>(bytes*8/bits));
}
check({nullptr,sizeof(data),count,8000,codec},0);
check({data,sizeof(data),0,8000,codec},0);
check({data,0,count,8000,codec},0);
VoiceClip huge{data,sizeof(data)-1,std::numeric_limits<uint32_t>::max(),8000,codec};
assert(!huge.valid());check(huge,static_cast<unsigned>((sizeof(data)-1)*8/bits));
VoiceClip all_bytes{data,std::numeric_limits<size_t>::max(),
std::numeric_limits<uint32_t>::max(),8000,codec};
assert(all_bytes.requiredBytes(all_bytes.samples)==
(static_cast<uint64_t>(all_bytes.samples)*bits+7)/8);
assert(all_bytes.valid()); // Validation only: actual pointer length is caller-owned.
auto other=codec==VoiceCodec::G72616?VoiceCodec::G72624:VoiceCodec::G72616;
for(auto invalid:{other,static_cast<VoiceCodec>(255)}){
VoiceClip bad{data,sizeof(data),count,8000,invalid};
assert(!bad.valid());check(bad,0);
}
assert(!VoiceClip(data,sizeof(data),count,16000,codec).valid());
// IMA remains selectable in an experiment, with its original 4-argument API.
const uint8_t ima[]={0x77};
VoiceClip original{ima,sizeof(ima),2};assert(original.codec==VoiceCodec::Ima&&original.valid());
decoder.begin(original);assert(decoder.next()==11&&decoder.next()==41&&decoder.done());
check(full,count);decoder.begin(original);assert(decoder.next()==11);
}
'''
PLAYER_MAIN = r'''
int main(){
constexpr auto codec=VoiceCodec::G726@RATE@;
const uint8_t data[]={@DATA@};
const int16_t expected[]={@PCM@};
constexpr unsigned count=sizeof(expected)/sizeof(expected[0]);
GpsVoicePlayer player;
assert(player.setGain(2));
for(unsigned n:{1u,3u,31u,32u,33u,63u,64u,65u,count}){
VoiceClip clip{data,sizeof(data),n,8000,codec};
assert(player.start(clip)&&player.active()&&player.playing());
assert(HwPWM3.owned&&HwPWM3.bound_pin==PIN_BUZZER&&irq_enabled);
assert(registers.COUNTERTOP==500&&registers.SEQ[0].REFRESH==0);
VoiceDecoder reference;reference.begin(clip);
VoiceInterpolator reconstruction;reconstruction.begin();
for(unsigned i=0;i<2;++i){
auto values=reinterpret_cast<const uint16_t*>(registers.SEQ[i].PTR);
assert(registers.SEQ[i].CNT==128);
for(unsigned j=0;j<128;++j){
int32_t pcm=reconstruction.next(reference)*2;
if(pcm>32767)pcm=32767;
if(pcm < -32768)pcm=-32768;
assert(values[j]==(0x8000|static_cast<uint16_t>((pcm+32768)*500/65536)));
}
}
unsigned sequence=0,events=0;
while(player.playing()){
assert(++events<100);registers.EVENTS_SEQEND[sequence]=1;player.interrupt();sequence^=1;
}
assert(player.played()==n&&events==(n*4+127)/128&&player.active());
player.service();assert(!player.active()&&!HwPWM3.owned&&!HwPWM3.pin&&!irq_enabled);
assert(!registers.ENABLE&&pin_states[PIN_BUZZER]==LOW);
}
assert(player.started()==9&&player.completed()==9&&player.cancelled()==0);
VoiceClip full{data,sizeof(data),count,8000,codec};
assert(player.start(full));player.stop();
assert(player.cancelled()==1&&!HwPWM3.owned&&!irq_enabled);
assert(!player.start({data,sizeof(data)-1,count,8000,codec}));
assert(!player.start({data,sizeof(data),count,16000,codec}));
assert(!player.start({nullptr,sizeof(data),count,8000,codec}));
assert(!player.start({data,sizeof(data),0,8000,codec}));
assert(!player.start({data,sizeof(data),count,8000,static_cast<VoiceCodec>(255)}));
auto other=codec==VoiceCodec::G72616?VoiceCodec::G72624:VoiceCodec::G72616;
assert(!player.start({data,sizeof(data),count,8000,other}));
HwPWM3.reject=true;assert(!player.start(full));
assert(!player.active()&&!HwPWM3.owned&&!irq_enabled&&!registers.ENABLE);
}
'''
@unittest.skipUnless(CC and CXX, 'Native C and C++ compilers are required')
class Tests(unittest.TestCase):
def build(self, source, bitrate, *, run=True, expected_error=None):
with tempfile.TemporaryDirectory() as temporary:
directory=Path(temporary)
(directory/'Arduino.h').write_text(shared.ARDUINO)
(directory/'HardwarePWM.h').write_text(shared.PWM)
(directory/'NonBlockingRtttl.h').write_text(shared.RTTTL)
path=directory/'test.cpp';path.write_text(source)
binary=directory/'test'
flags=[] if bitrate is None else [f'-DMESH_GPS_VOICE_G726_BITRATE={bitrate}',
'-DMESH_BUTTON_AUDIO_HIL=1']
if expected_error is not None:
result=subprocess.run([CXX,'-std=c++17','-I'+str(ROOT/'src'),
'-c',str(path),'-o',str(directory/'gate.o')],
capture_output=True,text=True,timeout=30)
self.assertNotEqual(result.returncode,0)
self.assertIn(expected_error,result.stderr)
return
objects=[]
if bitrate is not None:
obj=directory/'decoder.o'
subprocess.run([CC,'-std=c99','-Wall','-Wextra','-Werror',*SANITIZERS,*flags,
'-I'+str(ROOT/'src'),'-c',str(DECODER),'-o',str(obj)],
check=True,timeout=30)
objects.append(str(obj))
subprocess.run([CXX,'-std=c++17','-Wall','-Wextra','-Werror',*SANITIZERS,*flags,
'-I'+str(directory),'-I'+str(ROOT/'src'),'-I'+str(ROOT),
str(path),*objects,'-o',str(binary)],check=True,timeout=30)
if run:subprocess.run([str(binary)],check=True,timeout=30)
def fixture(self, source, bitrate):
data,pcm=VECTORS[bitrate]
return (PREAMBLE+source.replace('@RATE@',str(bitrate))
.replace('@BITS@',str(bitrate//8))
.replace('@DATA@',','.join('0x'+data[i:i+2] for i in range(0,len(data),2)))
.replace('@PCM@',','.join(map(str,pcm))))
def test_msb_reference_vectors_reset_padding_odd_lengths_and_bounds(self):
for bitrate in VECTORS:
with self.subTest(bitrate=bitrate):self.build(self.fixture(DECODE_MAIN,bitrate),bitrate)
def test_g726_shares_existing_pwm_interpolation_and_dma_lifecycle(self):
for bitrate in VECTORS:
with self.subTest(bitrate=bitrate):self.build(self.fixture(PLAYER_MAIN,bitrate),bitrate)
def test_segmented_stream_preserves_cross_byte_codes_adaptive_state_and_bounds(self):
source = DECODE_MAIN.split('int main(){', 1)[1].split(' assert(full.valid()', 1)[0]
source = 'int main(){' + source + r'''
for(size_t split=0;split<sizeof(data);++split){
VoiceClip clip{data+split,sizeof(data)-split,count,8000,codec,data,split};
assert(clip.valid());VoiceDecoder decoder;decoder.begin(clip);
for(unsigned i=0;i<count;++i){assert(!decoder.done());assert(decoder.next()==expected[i]);}
assert(decoder.done()&&decoder.position()==count);
for(size_t tail=0;tail<sizeof(data)-split;++tail){
std::vector<uint8_t> prefix(data,data+split),suffix(data+split,data+split+tail);
// A non-null placeholder is legal when the suffix's declared length is
// zero. ASan bounds the actual prefix/tail allocation independently.
VoiceClip short_clip{tail?suffix.data():data,tail,count,8000,codec,
split?prefix.data():nullptr,split};
assert(!short_clip.valid());decoder.begin(short_clip);
const unsigned available=static_cast<unsigned>((split+tail)*8/bits);
for(unsigned i=0;i<available;++i){assert(!decoder.done());assert(decoder.next()==expected[i]);}
assert(decoder.done()&&decoder.position()==available&&decoder.next()==0);
}
}
VoiceClip missing_prefix{data,sizeof(data),count,8000,codec,nullptr,1};
assert(!missing_prefix.valid());VoiceDecoder decoder;decoder.begin(missing_prefix);
assert(decoder.done()&&decoder.next()==0);
VoiceClip huge{data,std::numeric_limits<size_t>::max(),
std::numeric_limits<uint32_t>::max(),8000,codec,data,1};
assert(huge.valid()&&huge.hasBytes(std::numeric_limits<size_t>::max()));
GpsVoicePlayer player;assert(!player.start(missing_prefix)&&!player.active());
}
'''
for bitrate in VECTORS:
with self.subTest(bitrate=bitrate):self.build(self.fixture(source,bitrate),bitrate)
def test_invalid_bitrates_or_missing_laboratory_gate_cannot_compile(self):
for flags,error in (
('#define MESH_GPS_VOICE_G726_BITRATE 16\n','opt-in MESH_BUTTON_AUDIO_HIL'),
('#define MESH_BUTTON_AUDIO_HIL 0\n#define MESH_GPS_VOICE_G726_BITRATE 24\n',
'opt-in MESH_BUTTON_AUDIO_HIL'),
*[(f'#define MESH_BUTTON_AUDIO_HIL 1\n#define MESH_GPS_VOICE_G726_BITRATE {rate}\n',
'bitrate must be 16 or 24') for rate in (0,8,32,40)],
):
with self.subTest(flags=flags):
self.build(flags+'#include <helpers/ui/GpsVoice.h>\n',None,expected_error=error)
def test_default_ima_has_no_experimental_decoder_dependency(self):
self.build(PREAMBLE+r'''
int main(){
const uint8_t data[]={0x77};VoiceClip clip{data,sizeof(data),2};
VoiceDecoder decoder;decoder.begin(clip);
assert(decoder.next()==11&&decoder.next()==41&&decoder.done());
}
''',None)
REFERENCE_DIR = Path(os.environ.get('G726_REFERENCE_DIR',
str(ROOT/'out/gps-voice/g726-test')))
@unittest.skipUnless(CC and CXX and (REFERENCE_DIR/'report.json').is_file(),
'Optional independently decoded voice-catalog fixtures are unavailable')
class LocalVoiceCatalogTests(unittest.TestCase):
build = Tests.build
def test_all_32_actual_zira_g726_recordings_match_independent_ffmpeg_pcm(self):
report=json.loads((REFERENCE_DIR/'report.json').read_text(encoding='utf-8'))
expected_samples={name:recording[1] for name,recording in
{**ZIRA_RECORDINGS,**ZIRA_GPS_RECORDINGS}.items()}
self.assertEqual({clip['clip'] for clip in report['clips']}, set(expected_samples))
for bitrate in (16,24):
statements=[]
for clip in report['clips']:
name=clip['clip'];samples=clip['samples']
self.assertEqual(samples,expected_samples[name])
data=(REFERENCE_DIR/f'{name}.g726{bitrate}.be').read_bytes()
pcm_bytes=(REFERENCE_DIR/f'{name}.g726{bitrate}.decoded8k.s16le').read_bytes()
self.assertEqual(len(data),(samples*(bitrate//8)+7)//8)
self.assertEqual(len(pcm_bytes),samples*2)
self.assertEqual(hashlib.sha256(data).hexdigest(),
clip['codecs'][f'g726{bitrate}']['payload_sha256'])
pcm=struct.unpack(f'<{samples}h',pcm_bytes)
statements.append('''{
const uint8_t data[]={'''+','.join(map(str,data))+'''};
const int16_t expected[]={'''+','.join(map(str,pcm))+'''};
VoiceClip clip{data,sizeof(data),'''+str(samples)+''',8000,VoiceCodec::G726'''+str(bitrate)+'''};
assert(clip.valid());VoiceDecoder decoder;decoder.begin(clip);
for(unsigned i=0;i<clip.samples;++i){assert(!decoder.done());assert(decoder.next()==expected[i]);}
assert(decoder.done()&&decoder.position()==clip.samples&&decoder.next()==0);
}
''')
with self.subTest(bitrate=bitrate):
self.build(PREAMBLE+'int main(){\n'+''.join(statements)+'}\n',bitrate)
if __name__=='__main__':
unittest.main()