Files
EscapistsReintroduced/tools/build_bundled_speech.py
T

85 lines
4.1 KiB
Python

#!/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<string> 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)