#!/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 # 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. " "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): 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 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", content_type) 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