Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
330 lines
15 KiB
Python
330 lines
15 KiB
Python
#!/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
|