Bundle mission speech with an admin mute option and fix live audio downloads

This commit is contained in:
2026-10-15 16:53:39 +02:00
parent 07c4d70de5
commit 009b2fb591
181 changed files with 19248 additions and 103 deletions
+84
View File
@@ -0,0 +1,84 @@
#!/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)
+4 -2
View File
@@ -31,6 +31,7 @@ def check():
subprocess.run([sys.executable,str(ROOT/'tests/test_item_station_lifecycle.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_profile_config.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_welcome_voice.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_bundled_speech.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_speech_preparation.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_welcome_lifecycle.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_welcome_identity.py')],check=True)
@@ -43,6 +44,7 @@ def check():
subprocess.run([sys.executable,str(ROOT/'tests/test_speech_service.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_speech_menu.py')],check=True)
print(f'PASS: {check_prefabs((ROOT/"addons").rglob("*.et"))} prefabs have unique saved root entity IDs')
subprocess.run([sys.executable,str(ROOT/'tests/test_atlas_markers.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_objective_map_presentation.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_objective_map_element.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_native_objective_map_probe.py')],check=True)
@@ -96,7 +98,7 @@ def check():
assert set(ALLOWED) <= visibility['core'], 'Combined addon requires the authorized external closure'
vanilla=json.loads((ROOT/'docs/vanilla-resources.json').read_text())
for p in (ROOT/'addons').rglob('*'):
if not p.is_file() or p.suffix not in ['.c','.et','.conf','.ent','.layer','.layout','.emat','.bt']:continue
if not p.is_file() or p.suffix not in ['.c','.et','.conf','.ent','.layer','.layout','.emat','.bt','.acp']:continue
s=p.read_text()
assert not re.search(r'\b(?:ESCT\w*|SCRTC_\w+|SCRT_\w+|EAI_\w+)\b',s),f'Original ecosystem dependency: {p}'
for g,path in hard_resource_references(p.relative_to(ROOT).as_posix(),s):
@@ -104,7 +106,7 @@ def check():
if g in owners:
addon=p.relative_to(ROOT/'addons').parts[0]
assert owners[g] in visibility[addon],f'Resource outside declared dependency graph in {p}: {g} {path}'
if p.suffix in ['.et','.conf','.ent','.layout','.emat','.bt']:
if p.suffix in ['.et','.conf','.ent','.layout','.emat','.bt','.acp']:
assert p.with_name(p.name+'.meta').exists(),f'Missing metadata: {p}'
inventory=json.loads((ROOT/'docs/original-resource-inventory.json').read_text())
coverage=json.loads((ROOT/'docs/original-feature-coverage.json').read_text())
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""One-off speech asset generation on the credential-owning speech host.
Resumes completed files and reuses the live helper's existing cache. It never
changes that cache, credentials, mission config or the running helper.
"""
import argparse
import hashlib
import json
from pathlib import Path
from welcome_voice import ENDPOINT, VETERAN_PROMPT, save_clip, synthesize, validate_wav
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--cache', type=Path)
parser.add_argument('--project')
args = parser.parse_args()
manifest = json.loads(args.manifest.read_text())
voice, language = manifest['voice'], manifest['language']
identity = [ENDPOINT, voice, language, '', VETERAN_PROMPT]
args.output.mkdir(parents=True, exist_ok=True)
report = []
for entry in manifest['clips']:
name, text = entry['id'], entry['text']
assert name.replace('_', '').isalnum() and 1 <= len(text) <= 800
path = args.output / (name + '.wav')
marker = args.output / (name + '.json')
key = hashlib.sha256(json.dumps([identity, text], ensure_ascii=False).encode()).hexdigest()
origin = 'generated'
if path.exists() and marker.exists() and json.loads(marker.read_text())['cache_key'] == key:
audio = validate_wav(path.read_bytes())
origin = 'resumed'
elif args.cache and (args.cache / (key + '.wav')).is_file():
audio = validate_wav((args.cache / (key + '.wav')).read_bytes())
origin = 'cached'
else:
import google.auth
from google.auth.transport.requests import AuthorizedSession
credentials, _ = google.auth.default(
scopes=['https://www.googleapis.com/auth/cloud-platform'], quota_project_id=args.project)
with AuthorizedSession(credentials, max_refresh_attempts=0) as session:
audio = synthesize(session, ENDPOINT, voice, language, text)
save_clip(path, audio)
record = {'id': name, 'cache_key': key, 'sha256': hashlib.sha256(audio).hexdigest(),
'bytes': len(audio), 'origin': origin, 'voice': voice}
marker.write_text(json.dumps(record, indent=2) + '\n')
report.append(record)
print(f"{name}: {origin}, {len(audio)} bytes", flush=True)
(args.output / 'generation.json').write_text(json.dumps(report, indent=2) + '\n')
if __name__ == '__main__':
try:
main()
except Exception as exc:
# Provider exceptions can contain credentials/URLs; retain a fixed error.
raise SystemExit(f'Bundled speech generation failed ({type(exc).__name__}); completed assets retained.') from None
+7 -2
View File
@@ -14,7 +14,8 @@ p.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
p.add_argument('--external-addons', type=Path, help='Installed RHS/WCS/BMP source root; expose only audited closure in a separate Chat profile')
p.add_argument('--compat', type=Path, required=True, help='Disposable cloned Proton compatdata directory outside Steam')
p.add_argument('--profile', type=Path, required=True, help='Separate disposable successor Workbench profile')
p.add_argument('--smoke', choices=['coordinated-ai', 'crash-start', 'crash-geometry', 'normal-escape-route', 'escape-route', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'self-epinephrine', 'starter-backpack', 'objective-map', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'roadblocks', 'hq-local-handoff', 'infantry-transfer', 'hq-body', 'convoy', 'supply-convoys', 'supply-travel', 'mounted-ammo', 'alarm-hearing', 'hq-memory', 'hq-awareness', 'aircraft-field', 'aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
p.add_argument('--smoke', choices=['coordinated-ai', 'crash-start', 'crash-geometry', 'normal-escape-route', 'escape-route', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'self-epinephrine', 'starter-backpack', 'speech-download', 'bundled-speech', 'objective-map', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'roadblocks', 'hq-local-handoff', 'infantry-transfer', 'hq-body', 'convoy', 'supply-convoys', 'supply-travel', 'mounted-ammo', 'alarm-hearing', 'hq-memory', 'hq-awareness', 'aircraft-field', 'aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
p.add_argument('--speech-probe-url', help='Loopback-only cached audio fixture; requires --smoke speech-download')
p.add_argument('--trace-random-radius', action='store_true', help='Workbench-only read-only zero-radius caller traces; one normal Chat round, capped at sixteen records')
p.add_argument('--maintenance-refuel', action='store_true', help='Opt-in normal-route check: original held Southern fuel can transfers finite fuel through its native UAZ action; one arranged tank depletion, no stock creation')
p.add_argument('--mounted-swap-eligibility', action='store_true', help='Read-only mounted-ammo variant: one exact finite cargo/mounted magazine CanSwap call; no transfer or radio proof')
@@ -139,6 +140,10 @@ for proc in Path('/proc').iterdir():
p.error('Another Workbench already uses this test profile')
except (FileNotFoundError, PermissionError, ProcessLookupError): pass
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720', '-w', '1280', '-h', '720', '-r', '30', '--', str(proton), 'run', str(wb/'ArmaReforgerWorkbenchSteamDiag.exe'), '-gproj', 'Z:'+str(ROOT/'addons/core/addon.gproj'), '-addonsDir', 'Z:'+str(ROOT/'addons'), '-profile', profile_arg, '-logconsole', '-wbModule=WorldEditor', '-load', 'Worlds/ERR_Everon.ent', '-plugin=ERR_ValidateWorldPlugin', '-errPlay', '1', '-errSmoke', '1', '-errSmokeOutcome', a.smoke, '-noThrow', '-disableCrashReporter']
if a.smoke == 'speech-download':
if a.project != 'core' or not a.speech_probe_url or not a.speech_probe_url.startswith('http://127.0.0.1:'): p.error('speech-download requires Core and an explicit loopback --speech-probe-url')
command += ['-errSpeechProbeURL', a.speech_probe_url]
elif a.speech_probe_url: p.error('--speech-probe-url requires --smoke speech-download')
if a.convoy_cover: command += ['-errConvoyCover', '1']
if a.mounted_intercept: command += ['-errMountedIntercept', '1']
if a.mounted_swap_eligibility: command += ['-errMountedSwapEligibility', '1']
@@ -158,7 +163,7 @@ if a.recon_takeover_only: command += ['-errReconTakeoverOnly', '1']
if a.body_recovery_only: command += ['-errBodyRecoveryOnly', '1']
if a.coordinated_blocked_only: command += ['-errCoordinatedBlockedOnly', '1']
if a.project == 'core':
if a.smoke not in {'coordinated-ai', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'self-epinephrine', 'starter-backpack', 'objective-map', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'roadblocks', 'native-end-active', 'native-end-preparing', 'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'victory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'}: p.error('Unsupported Core-only fixture')
if a.smoke not in {'coordinated-ai', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'self-epinephrine', 'starter-backpack', 'speech-download', 'bundled-speech', 'objective-map', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'roadblocks', 'native-end-active', 'native-end-preparing', 'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'victory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'}: p.error('Unsupported Core-only fixture')
command[command.index('-plugin=ERR_ValidateWorldPlugin')] = '-plugin=ERR_ValidateBaseWorldPlugin'
command[command.index('-errSmoke')] = '-errBaseSmoke'
if a.smoke in {'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'} and a.project != 'core': p.error('This fixture requires --project core')
+9
View File
@@ -26,6 +26,7 @@ PROJECTS = {'core': ('ERR_Core', '2528466DE220D91C', ())}
DIRECTORIES = {'Scripts', 'Prefabs', 'Configs', 'Missions', 'Worlds', 'UI', 'Materials', 'AI'}
SUFFIXES = {'.c', '.et', '.conf', '.ent', '.layer', '.layout', '.emat', '.bt', '.meta', '.gproj', '.imageset', '.png', '.edds'}
BUNDLED_SPEECH = re.compile(r'addons/core/Sounds/ERR/Speech/(?:welcome|soldier_[a-z0-9_]+|radio_[0-9]{2})\.(?:wav|acp)(?:\.meta)?$')
# Locally authored atlas, with its editable FBX and native import result.
# Keep binary-model admission scoped to this reviewed asset, not arbitrary Assets/.
ATLAS_ROOT = 'addons/core/Assets/Items/ERR_Atlas/'
@@ -118,6 +119,8 @@ def inventory(root):
pass
elif name.startswith(ATLAS_ROOT) and name[len(ATLAS_ROOT):] in ATLAS_FILES:
pass
elif BUNDLED_SPEECH.fullmatch(name):
pass
elif len(parts) < 4 or parts[2] not in DIRECTORIES or path.suffix not in SUFFIXES - {'.gproj'}:
raise ValueError(f'unreviewed addon source/resource format: {name}')
result[name] = digest
@@ -156,6 +159,12 @@ def validate(payload, root):
suffix = PurePosixPath(name).suffix
if suffix == '.meta':
continue
if suffix == '.wav':
from welcome_voice import validate_wav
if not BUNDLED_SPEECH.fullmatch(name) or name + '.meta' not in payload:
raise ValueError(f'unregistered bundled speech: {name}')
validate_wav(data)
continue
if name.startswith(ATLAS_ROOT) and suffix in {'.fbx', '.xob', '.txo'}:
stem = str(PurePosixPath(name).with_suffix(''))
if any(stem + required not in payload for required in ('.xob', '.xob.meta', '.fbx')):
+28 -4
View File
@@ -25,6 +25,9 @@ WELCOME = ("Welcome to Escape Reintroduced. Work together, stay alert, "
"and find your way off the island. Good luck.")
ENDPOINT = "https://texttospeech.googleapis.com/v1/text:synthesize"
MAX_AUDIO = 2 * 1024 * 1024
# Enfusion rejects REST responses >= 1 MB. Keep each JSON/base64 response well
# below that ceiling; this is transport slicing, not audio recompression.
VOICE_CHUNK = 262144
VETERAN_PROMPT = ("Speak as a fictional older war veteran, about sixty-five: a weathered, "
"slightly gravelly male voice, calm authority and dry warmth. "
"A concise field briefing to fellow soldiers, not a movie trailer. "
@@ -251,20 +254,41 @@ def handler_for(data, briefings=None):
print("TTS request completed (200)", flush=True)
def do_GET(self):
match = re.fullmatch(r"/clips/([0-9a-f]{64})\.b64", self.path)
url = urlsplit(self.path)
match = re.fullmatch(r"/clips/([0-9a-f]{64})\.b64", url.path)
if not match and url.path not in ("/welcome.wav", "/welcome.b64"):
self.send_error(404)
return
part = None
if url.query:
query = re.fullmatch(r"part=([0-9]{1,2})", url.query)
if not query or not url.path.endswith(".b64"):
self.send_error(400)
return
part = int(query[1])
if match and briefings:
try:
payload = base64.b64encode(briefings.read(match[1]))
except (OSError, ValueError):
self.send_error(404)
return
elif self.path in ("/welcome.wav", "/welcome.b64"):
payload = encoded if self.path == "/welcome.b64" else data
elif url.path in ("/welcome.wav", "/welcome.b64"):
payload = encoded if url.path == "/welcome.b64" else data
else:
self.send_error(404)
return
content_type = "text/plain" if url.path.endswith(".b64") else "audio/wav"
if part is not None:
offset = part * VOICE_CHUNK
if offset >= len(payload):
self.send_error(416)
return
payload = json.dumps({"offset": offset, "total": len(payload),
"data": payload[offset:offset + VOICE_CHUNK].decode("ascii")},
separators=(",", ":")).encode("ascii")
content_type = "application/json"
self.send_response(200)
self.send_header("Content-Type", "text/plain" if self.path.endswith(".b64") else "audio/wav")
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)