#!/usr/bin/env python3 """《深夜茶馆》live 试玩驱动:真 Chromium + 真实混音 + SenseVoice ASR。 通过 control.mjs HTTP 接口逐步玩游戏,/listen 落判定日志。 用法: python3 tools/../research/new8/teahouse/live_drive.py [A|B] """ import json import sys import time import urllib.request BASE = "http://localhost:8157" def post(path, body): req = urllib.request.Request(BASE + path, data=json.dumps(body).encode(), headers={"content-type": "application/json"}, method="POST") with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read().decode()) def get(path): with urllib.request.urlopen(BASE + path, timeout=30) as r: return json.loads(r.read().decode()) LOG = [] def log(msg): line = f"[{time.strftime('%H:%M:%S')}] {msg}" print(line, flush=True) LOG.append(line) def gesture(g): post("/gesture", {"type": g}) log(f"→ gesture {g}") def drain_listen(tag): try: rows = get("/listen") except Exception as e: log(f" (listen err {e})") return for r in rows.get("rows", []) if isinstance(rows, dict) else rows: v = r.get("verdict", "?") hid = r.get("voiceId") or "" sim = r.get("similarity") sims = f" sim={sim:.2f}" if isinstance(sim, (int, float)) else "" log(f" ♪ {r.get('t0', 0):.1f}-{r.get('t1', 0):.1f}s {hid} {v}{sims}" + (f" 「{str(r.get('heard', ''))[:40]}」" if v != "OK" else "")) def sleep_listen(sec, tag=""): t0 = time.time() while time.time() - t0 < sec: time.sleep(min(4, sec - (time.time() - t0))) drain_listen(tag) EVT_SINCE = [0] def wait_evt(name, timeout=60, poll=0.8, ev=None, nth=1): """轮询 /events 直到 name 子串第 nth 次出现。 ev='vend' 锚「语音句播完」(等待态开放);ev='start' 锚「sfx 起播」(时间线零点)。""" seen = 0 t0 = time.time() while time.time() - t0 < timeout: time.sleep(poll) try: rows = get(f"/events?since={EVT_SINCE[0]}") except Exception: continue evts = rows.get("events", []) if isinstance(rows, dict) else rows if isinstance(rows, dict) and "next" in rows: EVT_SINCE[0] = rows["next"] for e in evts: if e.get("ev") in ("fetch", "asset404", "console.error", "console.warn"): continue # 只认真实播放/语音事件(prewarm fetch 会误锚) if ev and e.get("ev") != ev: continue nm = str(e.get("name", "")) + str(e.get("voiceId", "")) + str(e.get("url", "")) if name in nm: seen += 1 if seen >= nth: return True log(f" (等待事件 {name}#{nth} 超时 {timeout}s)") return False def wait_any(conds, timeout=60, poll=0.8): """conds=[(ev, name), ...]:单窗口内任一条件命中即返回命中索引(-1=超时)。 游标只在本窗口结束时前进一次——前置等待不会吞掉后续锚点。""" t0 = time.time() while time.time() - t0 < timeout: time.sleep(poll) try: rows = get(f"/events?since={EVT_SINCE[0]}") except Exception: continue evts = rows.get("events", []) if isinstance(rows, dict) else rows if isinstance(rows, dict) and "next" in rows: EVT_SINCE[0] = rows["next"] for e in evts: if e.get("ev") in ("fetch", "asset404", "console.error", "console.warn"): continue nm = str(e.get("url", "")) + str(e.get("name", "")) for i, (ev, name) in enumerate(conds): if (not ev or e.get("ev") == ev) and name in nm: return i log(f" (等待事件 {conds} 超时 {timeout}s)") return -1 def wait_cue(voice_id, timeout=60, poll=1.2): """轮询 /listen 直到目标 voiceId 被判定(OK/MISMATCH 均可,TRUNCATED 也算落声)。""" t0 = time.time() while time.time() - t0 < timeout: time.sleep(poll) rows = None try: rows = get("/listen") except Exception: continue for r in (rows.get("rows", []) if isinstance(rows, dict) else rows): v = r.get("verdict", "?") hid = r.get("voiceId") or "" sim = r.get("similarity") sims = f" sim={sim:.2f}" if isinstance(sim, (int, float)) else "" log(f" ♪ {r.get('t0', 0):.1f}-{r.get('t1', 0):.1f}s {hid} {v}{sims}" + (f" 「{str(r.get('heard', ''))[:40]}」" if v not in ("OK",) else "")) if hid == voice_id: return True log(f" (等待 {voice_id} 超时 {timeout}s)") return False def session_a(): post("/session", {"game": "teahouse", "settings": {"voice": "female", "speed": "normal", "bgm": "normal", "guide": "full"}}) EVT_SINCE[0] = 0 log("== R2 局A′:female/normal/full(F1/F3 教学新句验证) ==") wait_evt("probe_ready", 150, poll=1.0) gesture("tap") wait_evt("bgm/menu", 60, poll=1.0) time.sleep(1.5) gesture("tap") log("-- D0 --") wait_evt("tut_d0", 120, ev="vend") gesture("swiperight") wait_evt("tut_tea", 60, ev="vend") gesture("tap") log("-- D2:F1 新 tut_water(对照口径) --") wait_evt("water_pour", 40, ev="start") sleep_listen(2.2) gesture("doubletap") r = wait_any([("vend", "water_ok"), ("vend", "water_early"), ("vend", "water_late")], 40) log(f" 候汤归因档 = {['ok','early','late'][r] if r >= 0 else '??'}") log("-- D3:F1 新 tut_steep --") wait_evt("steep_rustle", 60, ev="start") sleep_listen(4.5) gesture("doubletap") wait_evt("tut_serve", 60, ev="vend") gesture("tap") log("-- D5:F3 新 tut_status --") wait_evt("tut_status", 90, ev="vend") gesture("swipeup") wait_evt("tut_done", 90, ev="vend") log("-- F2 复验需新档;本局收尾 --") drain_listen("end") out = post("/stop", {}) log(f"== R2 局A′ 结束 == {json.dumps(out)[:200]}") def session_b(): post("/session", {"game": "teahouse", "settings": {"voice": "male", "speed": "normal", "bgm": "normal", "guide": "full"}}) EVT_SINCE[0] = 0 log("== R2 局B′:male/normal/full(F2 跳过兜底验证) ==") wait_evt("probe_ready", 150, poll=1.0) gesture("tap") wait_evt("bgm/menu", 60, poll=1.0) time.sleep(1.5) gesture("tap") wait_evt("tut_d0", 120, ev="vend") gesture("swipedown") # 跳过教学 ok = wait_any([("vend", "tut_skip"), ("vend", "sys_help")], 40) log(f" 跳过链: tut_skip={'已播' if ok >= 0 else '??'} sys_help={'已播(F2 ✓)' if ok == 1 else '未播'}") drain_listen("end") out = post("/stop", {}) log(f"== R2 局B′ 结束 == {json.dumps(out)[:200]}") if __name__ == "__main__": which = sys.argv[1] if len(sys.argv) > 1 else "A" try: session_a() if which == "A" else session_b() except Exception as e: log(f"!! 异常: {e}") try: post("/stop", {}) except Exception: pass with open(f"/tmp/tea_live_{which}.log.full", "w") as f: f.write("\n".join(LOG))