#!/usr/bin/env python3 """Register generated speech WAVs, native graphs and their exact allowlist.""" import argparse import hashlib import json from pathlib import Path import re import shutil from welcome_voice import validate_wav ROOT = Path(__file__).resolve().parents[1] SOUNDS = 'Sounds/ERR/Speech/' def guid(path): return hashlib.sha256(('ERR bundled speech v1:' + path).encode()).hexdigest()[:16].upper() def resource(path): return '{' + guid(path) + '}' + path def metadata(path, kind): # Sound import defaults use the engine's per-platform resource settings. text = 'MetaFileClass {\n Name "' + resource(path) + '"\n Configurations {\n' for platform in ('PC', 'XBOX_ONE', 'XBOX_SERIES', 'PS4', 'PS5', 'HEADLESS'): parent = '' if platform == 'PC' else ' : PC' text += f' {kind} {platform}{parent} {{\n }}\n' return text + ' }\n}\n' def build(source, root=ROOT): manifest = json.loads((root / 'docs/bundled-speech.json').read_text()) graph_source = (root / 'addons/core/Scripts/Game/ERR/10_AI/ERR_SoldierSpeechAudio.c').read_text() graph = ''.join(json.loads('"' + value + '"') for value in re.findall(r'graph \+= "((?:[^"\\]|\\.)*)";', graph_source)) assert graph.count('%1') == 1 and 'spatialFactor 1' in graph target = root / 'addons/core' / SOUNDS target.mkdir(parents=True, exist_ok=True) report = [] for clip in manifest['clips']: name = clip['id'] audio = validate_wav((source / (name + '.wav')).read_bytes()) record = json.loads((source / (name + '.json')).read_text()) assert hashlib.sha256(audio).hexdigest() == record['sha256'] wav = SOUNDS + name + '.wav' acp = SOUNDS + name + '.acp' shutil.copyfile(source / (name + '.wav'), root / 'addons/core' / wav) (root / 'addons/core' / (wav + '.meta')).write_text(metadata(wav, 'WAVResourceClass')) rendered = graph.replace('%1', resource(wav)) if clip['category'] != 'soldier': rendered = rendered.replace('spatialFactor 1', 'spatialFactor 0') (root / 'addons/core' / acp).write_text(rendered + '\n') (root / 'addons/core' / (acp + '.meta')).write_text(metadata(acp, 'AudioProjectResourceClass')) report.append({**clip, 'sha256': record['sha256'], 'bytes': len(audio), 'graph': resource(acp)}) script = ['// Generated by tools/build_bundled_speech.py from docs/bundled-speech.json.', '// Only these packaged graphs are accepted as local speech sources.', 'class ERR_BakedSpeech', '{', ' static array Sources() { return {' + ', '.join(json.dumps('baked:' + c['id']) for c in report) + '}; }', ' static string Welcome() { return "baked:welcome"; }', ' static string WelcomeText() { return ' + json.dumps(manifest['clips'][0]['text']) + '; }', ' static ResourceName Graph(string source)', ' {', ' switch (source)', ' {'] for clip in report: script.append(' case "baked:' + clip['id'] + '": return "' + clip['graph'] + '";') script += [' }', ' return "";', ' }', ' static string Soldier(int kind)', ' {', ' switch (kind)', ' {'] for clip in report: if clip['category'] == 'soldier': script.append(' case ' + clip['kind'] + ': return "baked:' + clip['id'] + '";') script += [' }', ' return "";', ' }', ' static string Radio(string text)', ' {', ' switch (text)', ' {'] for clip in report: if clip['category'] == 'radio': script.append(' case ' + json.dumps(clip['text']) + ': return "baked:' + clip['id'] + '";') script += [' }', ' return "";', ' }', '}'] (root / 'addons/core/Scripts/Game/ERR/00_Core/ERR_BakedSpeech.c').write_text('\n'.join(script) + '\n') (root / 'docs/bundled-speech-assets.json').write_text(json.dumps({'voice': manifest['voice'], 'clips': report}, indent=2) + '\n') print(f'Registered {len(report)} speech clips ({sum(c["bytes"] for c in report)} PCM bytes).') if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('source', type=Path) build(parser.parse_args().source)