Files
EscapistsReintroduced/tools/welcome_voice.py
T
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

330 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Generate Google welcome speech and serve cached, authenticated briefings.
Credentials use Google's Application Default Credentials and never enter the
addon or HTTP response. Public playback never calls the paid synthesis API;
the optional authenticated bridge generates and caches terrain/weather text.
"""
import argparse
import base64
import io
import json
import os
import hashlib
import hmac
import re
import threading
import time
from pathlib import Path
import tempfile
import wave
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
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
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. "
"Use a steady natural pace, short pauses, no shouting, music or sound effects. "
"Read only the supplied text, exactly; do not add details.")
class SpeechHTTPError(RuntimeError):
def __init__(self, status):
self.status = status
super().__init__(f"Google speech request failed (HTTP {status}); no retry")
def validate_wav(data):
if not 44 <= len(data) <= MAX_AUDIO:
raise ValueError("Clip must be a WAV smaller than 2 MiB")
try:
with wave.open(io.BytesIO(data), "rb") as wav:
if (wav.getcomptype() != "NONE" or wav.getsampwidth() != 2
or wav.getnchannels() != 1
or not 8000 <= wav.getframerate() <= 48000
or not 0 < wav.getnframes() / wav.getframerate() <= 30):
raise ValueError("Expected mono 16-bit PCM, 8–48 kHz, up to 30 seconds")
expected = wav.getnframes() * 2
if len(wav.readframes(wav.getnframes())) != expected:
raise ValueError("Truncated audio")
except (wave.Error, EOFError) as exc:
raise ValueError("Invalid PCM WAV") from exc
return data
def synthesize(session, endpoint, voice, language, text=WELCOME, model="", prompt=""):
# Reject credentials in URLs: providers authenticate through ADC headers.
url = urlsplit(endpoint)
if url.scheme != "https" or not url.hostname or url.username or url.password or url.query or url.fragment:
raise ValueError("Speech endpoint must be an HTTPS URL without credentials or query parameters")
request = {"input": {"text": text},
"voice": {"languageCode": language, "name": voice},
"audioConfig": {"audioEncoding": "LINEAR16", "sampleRateHertz": 24000}}
if model:
request["voice"]["modelName"] = model
request["input"]["prompt"] = prompt
# Explicitly disable redirects to avoid forwarding credentials to another host.
with session.post(endpoint, json=request, timeout=60, stream=True, allow_redirects=False) as response:
if response.status_code != 200:
raise SpeechHTTPError(response.status_code)
chunks = []
size = 0
for chunk in response.iter_content(65536):
size += len(chunk)
if size > MAX_AUDIO * 2:
raise ValueError("Speech response too large")
chunks.append(chunk)
try:
body = json.loads(b"".join(chunks))
data = base64.b64decode(body["audioContent"], validate=True)
except (ValueError, KeyError, TypeError) as exc:
raise ValueError("Invalid speech response") from exc
return validate_wav(data)
def save_clip(path, data):
validate_wav(data)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as output:
temporary = Path(output.name)
try:
with temporary.open("wb") as output:
output.write(data)
output.flush()
os.fsync(output.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
class BriefingCache:
"""Authenticated server generation; public playback is cache-only."""
def __init__(self, directory, token, generate, identity, clock=time.monotonic):
if not 32 <= len(token) <= 256:
raise ValueError("Bridge token must contain 32–256 characters")
self.directory, self.token, self.generate = Path(directory), token, generate
self.identity, self.clock = identity, clock
self.lock = threading.Lock()
self.last_attempt = -float("inf")
self.directory.mkdir(parents=True, exist_ok=True)
def prepare(self, request):
if not isinstance(request, dict):
raise ValueError("Expected object")
token = request.get("token", "")
if not isinstance(token, str) or not hmac.compare_digest(token.encode(), self.token.encode()):
raise PermissionError("Unauthorized")
text = request.get("text")
if not isinstance(text, str) or not 1 <= len(text) <= 800 or any(ord(c) < 32 for c in text):
raise ValueError("Invalid briefing")
key = hashlib.sha256(json.dumps([self.identity, text], ensure_ascii=False).encode()).hexdigest()
path = self.directory / (key + ".wav")
with self.lock:
if path.exists():
validate_wav(path.read_bytes())
return key
# Limit billable work even for authorized requests; failed attempts count.
now = self.clock()
if now - self.last_attempt < 60:
raise BlockingIOError("Synthesis rate limited")
if len(list(self.directory.glob("*.wav"))) >= 256:
raise BlockingIOError("Cache full; remove unused cached clips")
self.last_attempt = now
save_clip(path, self.generate(text))
return key
def read(self, key):
if not re.fullmatch(r"[0-9a-f]{64}", key):
raise FileNotFoundError()
return validate_wav((self.directory / (key + ".wav")).read_bytes())
def read_request_body(headers, stream, limit=8192):
"""Read fixed-length or chunked HTTP bodies without trusting their sizes."""
transfer = headers.get("Transfer-Encoding", "").strip().lower()
length = headers.get("Content-Length")
if not transfer:
if length is None or not re.fullmatch(r"[0-9]+", length):
raise ValueError("Invalid size")
size = int(length)
if not 0 < size <= limit:
raise ValueError("Invalid size")
data = stream.read(size)
if len(data) != size:
raise ValueError("Invalid HTTP body framing")
return data
if transfer != "chunked" or length is not None:
raise ValueError("Invalid HTTP body framing")
data = bytearray()
framing = 0
while True:
line = stream.readline(130)
framing += len(line)
if not line.endswith(b"\r\n") or len(line) > 128 or framing > 4096:
raise ValueError("Invalid HTTP body framing")
size_text = line[:-2].split(b";", 1)[0]
if not re.fullmatch(rb"[0-9a-fA-F]{1,8}", size_text):
raise ValueError("Invalid HTTP body framing")
size = int(size_text, 16)
if size == 0:
while True:
trailer = stream.readline(1026)
framing += len(trailer)
if not trailer.endswith(b"\r\n") or len(trailer) > 1024 or framing > 4096:
raise ValueError("Invalid HTTP body framing")
if trailer == b"\r\n":
if not data:
raise ValueError("Invalid size")
return bytes(data)
if b":" not in trailer:
raise ValueError("Invalid HTTP body framing")
if len(data) + size > limit:
raise ValueError("Invalid size")
chunk = stream.read(size)
if len(chunk) != size or stream.read(2) != b"\r\n":
raise ValueError("Invalid HTTP body framing")
data.extend(chunk)
def handler_for(data, briefings=None):
validate_wav(data)
encoded = base64.b64encode(data)
class CachedClip(BaseHTTPRequestHandler):
def setup(self):
super().setup()
self.connection.settimeout(15)
def do_POST(self):
if self.path != "/" or briefings is None:
self.send_error(404)
return
try:
body = read_request_body(self.headers, self.rfile)
key = briefings.prepare(json.loads(body))
payload = json.dumps({"clip": key}).encode()
except PermissionError:
print("TTS request rejected: authentication failed (403)", flush=True)
self.send_error(403)
return
except (ValueError, TypeError) as error:
# Only fixed diagnostics: never print the request, token,
# provider response or arbitrary exception message.
reasons = {
"Invalid size": "invalid_body_size",
"Invalid HTTP body framing": "invalid_http_body_framing",
"Expected object": "json_object_required",
"Invalid briefing": "invalid_briefing_text",
"Clip must be a WAV smaller than 2 MiB": "audio_size_invalid",
"Expected mono 16-bit PCM, 8–48 kHz, up to 30 seconds": "audio_format_or_duration_invalid",
"Truncated audio": "audio_truncated",
"Invalid PCM WAV": "audio_format_invalid",
"Invalid speech response": "provider_response_invalid",
"Speech response too large": "provider_response_too_large",
}
reason = reasons.get(str(error), "invalid_data")
if isinstance(error, json.JSONDecodeError):
reason = "invalid_json"
print(f"TTS request rejected: {reason} (400)", flush=True)
self.send_error(400)
return
except BlockingIOError:
print("TTS request deferred: synthesis limit reached (429)", flush=True)
self.send_error(429)
return
except Exception as error:
reason = "service_or_provider_failure"
if isinstance(error, SpeechHTTPError):
reason = "provider_http_" + str(int(error.status))
print(f"TTS request failed: {reason} (503)", flush=True)
self.send_error(503) # Never expose provider errors or secrets.
return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
print("TTS request completed (200)", flush=True)
def do_GET(self):
match = re.fullmatch(r"/clips/([0-9a-f]{64})\.b64", self.path)
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
else:
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "text/plain" if self.path.endswith(".b64") else "audio/wav")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *_):
pass # Do not log arbitrary request paths/headers.
return CachedClip
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--input-wav", type=Path, help="Use an existing clip without any API request")
parser.add_argument("--regenerate", action="store_true", help="Replace a cached clip with one new synthesis request")
parser.add_argument("--project", help="Google Cloud quota project")
parser.add_argument("--voice", default="en-US-Chirp3-HD-Algenib")
parser.add_argument("--language", default="en-US")
parser.add_argument("--model", default="", help="Optional prompt-controlled model, e.g. gemini-2.5-flash-tts with --voice Algenib")
parser.add_argument("--text", default=WELCOME, help="Text for the standalone cached clip")
parser.add_argument("--briefing-cache", type=Path, help="Enable authenticated terrain/weather generation")
parser.add_argument("--bridge-token-file", type=Path, help="Secret shared with the game server; never sent to clients")
parser.add_argument("--endpoint", default=ENDPOINT)
parser.add_argument("--serve", action="store_true")
parser.add_argument("--bind", default="127.0.0.1")
parser.add_argument("--port", type=int, default=18947)
args = parser.parse_args()
if args.briefing_cache and (not args.serve or not args.bridge_token_file):
parser.error("--briefing-cache requires --serve and --bridge-token-file")
def generate(text):
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:
return synthesize(session, args.endpoint, args.voice, args.language, text, args.model, VETERAN_PROMPT)
if args.input_wav:
save_clip(args.output, args.input_wav.read_bytes())
elif args.regenerate or not args.output.exists():
save_clip(args.output, generate(args.text))
data = validate_wav(args.output.read_bytes())
print(f"Welcome clip ready ({len(data)} bytes). This is synthetic speech.")
if args.serve:
briefings = None
if args.briefing_cache:
briefings = BriefingCache(args.briefing_cache, args.bridge_token_file.read_text().strip(),
generate, [args.endpoint, args.voice, args.language, args.model, VETERAN_PROMPT])
with ThreadingHTTPServer((args.bind, args.port), handler_for(data, briefings)) as server:
print(f"Serving cached audio on port {args.port}; authenticated briefing generation={briefings is not None}.", flush=True)
server.serve_forever()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
pass
except SpeechHTTPError as exc:
raise SystemExit(str(exc)) from None
except Exception as exc:
# Provider/network exceptions can contain URLs or credential details.
raise SystemExit(f"Welcome voice failed ({type(exc).__name__}). Check credentials, project/API access, endpoint and WAV format.") from None