File size: 21,902 Bytes
e46ffb6 ee79633 e46ffb6 ee79633 e46ffb6 ee79633 e46ffb6 ee79633 e46ffb6 ee79633 e46ffb6 ee79633 e46ffb6 b43da19 e46ffb6 b43da19 e46ffb6 ee79633 e46ffb6 ee79633 e46ffb6 ee79633 e46ffb6 ee79633 e46ffb6 b43da19 b0fb94f e46ffb6 b43da19 e46ffb6 ee79633 b0fb94f e46ffb6 b0fb94f b43da19 e46ffb6 ee79633 e46ffb6 ee79633 e46ffb6 b0fb94f b43da19 e46ffb6 b43da19 ee79633 b0fb94f e46ffb6 ee79633 b0fb94f b43da19 b0fb94f e46ffb6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 | """Explicit offline Chromium reader integration using mocked jobs and real MP3.
No inference, corpus or production calls. Supply an already generated real MP3.
Muting avoids this host's stalled physical audio sink; it is not audible/phone QA.
"""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import json
from pathlib import Path
import re
import sys
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from tts.text import playback_segments, utf16_offset
BASE = "http://127.0.0.1:19876"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sample", type=Path, required=True)
parser.add_argument("--output", type=Path, default=Path("/tmp/opencode/hitokoto-reader-browser.json"))
args = parser.parse_args()
audio = args.sample.read_bytes()
results = {"at": datetime.now(timezone.utc).isoformat(), "scope": __doc__, "cases": []}
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
try:
for mobile in (False, True):
context = browser.new_context(viewport={"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900})
jobs, requests, tokens, audio_requests, deleted = {}, [], {}, [], []
lost = [False]
hold_text, held = [None], []
page_errors = []
def reply(route, data, status=200):
route.fulfill(status=status, content_type="application/json", body=json.dumps(data), headers={"Cache-Control": "no-store"})
def describe(job):
state = "expired" if job.get("expired") else "cancelled" if job.get("cancelled") else "ready" if job["polls"] >= 5 else "running"
job["state"] = state
text = job["body"]["text"]
spans = [(utf16_offset(text, start), utf16_offset(text, end)) for start, end, _ in playback_segments(text)]
count = len(spans) if job["polls"] >= 3 else 1 if job["polls"] else 0
value = {"id": job["id"], "state": state, "cache_hit": False, "expires_in": 900,
"source_segments": [{"start": start, "end": end} for start, end in spans]}
if count and state not in {"cancelled", "expired"}:
value["progress"] = {"complete": state == "ready", "total_parts": len(spans), "parts": [
{"index": index, "total_parts": len(spans), "start": spans[index][0], "end": spans[index][1],
"audio_start": index * 10.6, "audio_end": (index + 1) * 10.6, "audio_seconds": 10.6,
"bytes": len(audio), "first_audio_seconds": .2,
"pitch_semitones": job["body"].get("pitch_semitones", 0),
"audio_url": f"/tts/audio/{job['id']}/parts/{index}.mp3"} for index in range(count)]}
if state == "ready":
value["result"] = {"audio_seconds": 21.2, "synthesis_seconds": 2.0, "audio_url": f"/tts/audio/{job['id']}.mp3",
"pitch_semitones": job["body"].get("pitch_semitones", 0)}
return value
def handler(route):
request = route.request
path = urlparse(request.url).path
names = {"/tts/": ("tts.html", "text/html"), "/tts/app.js": ("tts.js", "text/javascript"),
"/tts/reader.js": ("reader.js", "text/javascript"), "/tts/style.css": ("tts.css", "text/css")}
if path in names:
name, mime = names[path]
route.fulfill(content_type=mime, body=(ROOT / "static" / name).read_bytes())
elif path == "/tts/health":
reply(route, {"ready": True, "engine": {"threads": 2}, "queued": 0})
elif path == "/tts/voices":
reply(route, {"voices": [{"id": name, "name": name} for name in ("zf_001", "zf_017", "zm_010", "zm_029")]})
elif path == "/tts/jobs" and request.method == "POST":
body = request.post_data_json
requests.append(body)
token = body["request_id"]
if token not in tokens:
id = f"{len(jobs) + 1:032d}"
tokens[token] = id
jobs[id] = {"id": id, "body": body, "polls": 0, "state": "running"}
# Simulate an accepted POST whose response is lost. The
# retry must reuse the token, not allocate another job.
if mobile and not lost[0]:
lost[0] = True
route.abort("connectionfailed")
elif body["text"] == hold_text[0]:
hold_text[0] = None
held.append((route, describe(jobs[tokens[token]])))
else:
reply(route, describe(jobs[tokens[token]]))
elif re.fullmatch(r"/tts/jobs/[0-9]{32}", path):
job = jobs[path.rsplit("/", 1)[-1]]
if request.method == "DELETE":
job["cancelled"] = True
deleted.append(job["id"])
else:
job["polls"] += 1
reply(route, describe(job))
elif re.fullmatch(r"/tts/audio/[0-9]{32}/parts/[0-9]+\.mp3", path):
audio_requests.append(path)
data, status, headers = audio, 200, {"Cache-Control": "private, max-age=300"}
match = re.fullmatch(r"bytes=(\d+)-(\d*)", request.headers.get("range", ""))
if match:
start = int(match[1])
end = min(len(audio) - 1, int(match[2]) if match[2] else len(audio) - 1)
data, status = audio[start:end + 1], 206
headers["Content-Range"] = f"bytes {start}-{end}/{len(audio)}"
route.fulfill(status=status, content_type="audio/mpeg", body=data, headers=headers)
else:
reply(route, {"error": {"code": "not_mocked", "message": path}}, 404)
context.route(BASE + "/**", handler)
page = context.new_page()
page.set_default_timeout(60000)
page.on("pageerror", lambda error: page_errors.append(str(error)))
page.add_init_script('''document.addEventListener("DOMContentLoaded", () => {
const audio=document.querySelector("#audio");audio.muted=true;
});''')
try:
page.goto(BASE + "/tts/", wait_until="domcontentloaded")
page.wait_for_function('!document.querySelector("#generate").disabled')
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth + 1')
assert page.evaluate('localStorage.getItem(ReaderPlan.bookmarkKey)') is None
text = "这是一篇长文,校长文章写得很好,我得亲自处理。😀\n" * 42 + "<b>原文不是HTML</b>"
page.locator("#speech-text").fill(text)
page.locator("#pitch").evaluate("node=>{node.value=-2;node.dispatchEvent(new Event('input',{bubbles:true}));}")
assert page.locator("#pitch-value").text_content().startswith("-2")
page.locator("#playback-rate").evaluate("node=>{node.value=2;node.dispatchEvent(new Event('input',{bubbles:true}));}")
page.locator("#generate").click()
page.wait_for_function('document.querySelector("#audio").currentTime > .2 && !document.querySelector("#audio").paused')
assert page.evaluate('session.jobs.get(session.batch).state') == "running"
assert page.locator("#pitch").is_disabled()
assert all(body["pitch_semitones"] == -2 for body in requests)
page.locator("#play").click()
assert page.locator("#audio").evaluate("node => node.paused")
assert page.locator("#reader-source").text_content() == text
assert page.locator("#reader-source b").count() == 0
current = page.evaluate("({start:session.plan[session.current.batch].start+session.current.part.start,end:session.plan[session.current.batch].start+session.current.part.end})")
assert page.locator("#current-source").text_content() == text.encode('utf-16-le')[current['start']*2:current['end']*2].decode('utf-16-le')
assert page.locator('.source-part[aria-current="location"]').count() == 1
page.wait_for_function("session && !session.ticking")
post_count, clip_count = len(requests), len(audio_requests)
page.wait_for_timeout(1400)
assert len(requests) == post_count and len(audio_requests) == clip_count
assert page.evaluate("!preloader.hasAttribute('src') && !session.wantsPlay")
count = len(requests)
page.locator("#playback-rate").evaluate("node=>{node.value=1.75;node.dispatchEvent(new Event('input',{bubbles:true}));}")
assert page.locator("#audio").evaluate("node=>node.playbackRate") == 1.75
assert len(requests) == count
assert all(body['speed'] == 1 for body in requests)
page.locator("#playback-rate").evaluate("node=>{node.value=2;node.dispatchEvent(new Event('input',{bubbles:true}));}")
page.locator("#remember").check()
page.evaluate("checkpoint(true)")
saved = page.evaluate("JSON.parse(localStorage.getItem(ReaderPlan.bookmarkKey))")
assert saved["text"] == text and saved["seconds"] > 0
assert saved["playbackRate"] == 2
assert saved["pitchSemitones"] == -2
assert "audio_url" not in saved and "id" not in saved
if not mobile:
expired_id = page.evaluate("session.jobs.get(session.batch).id")
jobs[expired_id]["expired"] = True
page.wait_for_function("session.jobs.get(session.batch).id === ''")
page.wait_for_function("session && !session.ticking")
count = len(requests)
page.wait_for_timeout(1400)
assert len(requests) == count, "Expired paused jobs must not automatically resubmit"
assert page.locator("#audio").evaluate("node => node.paused"), "Recovery must respect a user pause"
page.locator("#play").click()
page.wait_for_function("session && session.batch >= 1 && session.current && !document.querySelector('#audio').paused")
assert page.evaluate("session.jobs.size") <= 2
assert len({body["request_id"] for body in requests}) >= 2
if mobile:
assert requests[0]["request_id"] == requests[1]["request_id"]
# Cancel an incomplete speculative batch on native pause;
# the current batch is retained and no third batch is queued.
page.wait_for_function("session.jobs.get(session.batch+1)?.id && session.jobs.get(session.batch+1)?.state === 'running'")
prefetched = page.evaluate("session.jobs.get(session.batch+1).id")
page.locator("#audio").evaluate("node=>node.pause()")
page.wait_for_function("!session.wantsPlay")
page.wait_for_function("!session.jobs.has(session.batch+1)")
page.wait_for_function("id=>!session.ticking", arg=prefetched)
page.wait_for_timeout(200)
assert prefetched in deleted
assert page.evaluate("session.jobs.size") <= 1
# Previous/next and direct cross-batch source clicks preserve
# a user pause and do not create tasks or fetch another MP3.
navigation = page.evaluate("session.navigationIndex")
page.locator("#next").click()
assert page.evaluate("session.navigationIndex") == navigation + 1
page.locator("#previous").click()
assert page.evaluate("session.navigationIndex") == navigation
target = page.evaluate("session.locations.findIndex(part=>part.batch===session.batch+1)")
assert target >= 0
page.locator(f'.source-part[data-location="{target}"]').click()
page.wait_for_function("session && !session.ticking")
count, clips = len(requests), len(audio_requests)
page.wait_for_timeout(1400)
assert len(requests) == count and len(audio_requests) == clips
assert page.evaluate("session.navigationIndex") == target
assert page.evaluate("!session.current && !session.wantsPlay")
selected = page.evaluate("session.locations[session.navigationIndex].start")
page.locator("#locate").click()
page.locator("#play").click()
page.wait_for_function("session && session.current && !document.querySelector('#audio').paused")
assert page.evaluate("session.plan[session.current.batch].start+session.current.part.start") == selected
# Rapid in-session seeks must not allow stale job responses
# to change the selected source or continue obsolete work.
page.locator("#next").click()
page.locator("#previous").click()
page.wait_for_function("session && session.current && !document.querySelector('#audio').paused")
assert page.evaluate("session.plan[session.current.batch].start+session.current.part.start") == selected
page.evaluate('Object.defineProperty(document,"hidden",{configurable:true,get:()=>true});document.dispatchEvent(new Event("visibilitychange"))')
assert page.evaluate("session !== null")
assert not page.locator("#audio").evaluate("node => node.paused")
page.locator("#play").click()
page.evaluate("checkpoint(true)")
saved = page.evaluate("JSON.parse(localStorage.getItem(ReaderPlan.bookmarkKey))")
post_count = len(requests)
page.reload(wait_until="domcontentloaded")
page.wait_for_function('!document.querySelector("#resume").disabled')
assert len(requests) == post_count, "Restoring text must not automatically submit it"
assert page.locator("#speech-text").input_value() == text
assert page.locator("#playback-rate").input_value() == "2"
assert page.locator("#pitch").input_value() == "-2"
page.locator("#resume").click()
page.wait_for_function("session && session.current && document.querySelector('#audio').readyState >= 2")
assert page.evaluate("session.plan[session.current.batch].start + session.current.part.start") == saved["offset"]
page.wait_for_function("document.querySelector('#audio').currentTime > .1")
page.locator("#forget").click()
assert page.evaluate("localStorage.getItem(ReaderPlan.bookmarkKey)") is None
page.locator("#stop").click()
assert not page.locator("#audio").evaluate("node => node.hasAttribute('src')")
assert not page.locator("#pitch").is_disabled()
# Hold a speculative POST response after server acceptance,
# then pause before the capability comes back to the reader.
# Its late response must be released, not adopted/replayed.
hold_text[0] = page.evaluate("ReaderPlan.split(document.querySelector('#speech-text').value)[1].text")
page.locator("#generate").click()
page.wait_for_function("session && session.current && session.jobs.has(session.batch+1) && session.ticking && !session.jobs.get(session.batch+1).id")
assert len(held) == 1
page.locator("#play").click()
route, late = held.pop()
reply(route, late)
page.wait_for_function("session && !session.ticking")
page.wait_for_timeout(200)
assert late["id"] in deleted
assert page.evaluate("!session.wantsPlay && session.jobs.size===1 && !preloader.hasAttribute('src')")
page.locator("#stop").click()
# Sleep mode stops at the exact current clip anchor, saves
# the next original source position and releases owned work.
page.locator("#generate").click()
page.wait_for_function("session && session.current && document.querySelector('#audio').currentTime>.1")
assert page.locator("#audio").evaluate("node=>node.playbackRate") == 2
page.locator("#remember").check()
page.locator("#sleep-mode").select_option("clip")
sleep_end = page.evaluate("session.plan[session.current.batch].start+session.current.part.end")
page.locator("#audio").evaluate("node=>{node.currentTime=node.duration-.05;}")
page.wait_for_function("session===null && document.querySelector('#status').textContent.includes('睡眠定时')")
assert page.evaluate("bookmark.offset") == sleep_end
assert page.evaluate("!preloader.hasAttribute('src') && sleep.mode==='off'")
page.locator("#resume").click()
page.wait_for_function("session && session.current && document.querySelector('#audio').currentTime>.1")
assert page.evaluate("session.plan[session.current.batch].start+session.current.part.start") == sleep_end
page.locator("#play").click()
page.locator("#sleep-mode").select_option("5")
assert page.evaluate("sleep.remaining()") <= 300
assert page.locator("#audio").evaluate("node=>node.paused")
# Inject only the pure timer's clock; no actual five-minute
# wait or fake inference is added to the default suite.
page.evaluate("sleep.clock=()=>sleep.deadline;document.dispatchEvent(new Event('visibilitychange'))")
page.wait_for_function("session===null")
assert page.locator("#status").text_content().startswith("睡眠定时")
assert page.evaluate("!document.querySelector('#audio').hasAttribute('src') && sleep.mode==='off'")
assert page.evaluate("!('mediaSession' in navigator) || navigator.mediaSession.playbackState==='none'")
page.locator("#forget").click()
assert not page_errors, page_errors
results["cases"].append({"mobile_viewport": mobile, "early_play_before_completion": True,
"continuous_cross_batch": True, "bounded_prefetch": True,
"bookmark_opt_in_reload_resume_clear": True,
"lost_post_idempotent_retry": mobile, "expired_audio_recovery_while_paused": not mobile,
"source_highlight_next_previous_click_seek": True,
"pause_no_new_posts_or_preloads": True, "unfinished_prefetch_cancelled": True,
"rapid_seek_stale_response_guard": True,
"pause_during_post_late_capability_released": True,
"instant_playback_rate_no_resynthesis_and_bookmark": True,
"pitch_request_snapshot_and_opt_in_bookmark": True,
"sleep_current_clip_stops_before_next_and_resumes": True,
"sleep_deadline_while_paused_releases_work": True,
"visibility_change_does_not_stop": True, "page_errors": page_errors})
print("Reader mobile" if mobile else "Reader desktop", "PASS", flush=True)
finally:
context.close()
finally:
browser.close()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(results, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
|