Bundle mission speech with an admin mute option and fix live audio downloads
This commit is contained in:
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user