Files
aego/tools/websmoke/audio.mjs
T
sanspie a6822156cd
ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 8m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m46s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m28s
Finish Stage 12 editor fixes and refreshed platformer
2026-11-05 17:36:42 +03:00

200 lines
10 KiB
JavaScript

import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { chromium } from "playwright-core";
const opts = Object.fromEntries(
process.argv.slice(2).map((a) => {
const [k, v] = a.replace(/^--/, "").split("=");
return [k, v ?? "true"];
}),
);
const site = path.resolve(opts.site ?? "../../site");
const executablePath = process.env.CHROMIUM || "/usr/bin/chromium";
const seconds = +(opts.seconds ?? 10);
const demoSeconds = +(opts["demo-seconds"] ?? 2.5);
if (!Number.isFinite(demoSeconds) || demoSeconds < 2.5) throw new Error("demo-seconds must be at least 2.5");
const blocks = (opts.blocks ?? "").split(",").filter(Boolean).map(Number);
const mime = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".wasm": "application/wasm",
".json": "application/json",
};
const isolate = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
};
const failures = [];
function fail(msg) {
failures.push(msg);
console.error("FAIL " + msg);
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// Playwright evaluate/waitForFunction can synthesize gestures; audio/autoplay probes must not activate the page before real pointer input, even during startup.
async function scriptWithoutGesture(page) {
const cdp = await page.context().newCDPSession(page);
const js = async (expression) => {
const r = await cdp.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true, userGesture: false });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
return r.result.value;
};
const until = async (expression, timeout) => {
const end = Date.now() + timeout;
while (!(await js(expression))) {
if (Date.now() > end) throw new Error("timed out waiting for " + expression);
await sleep(100);
}
};
return { js, until };
}
function serve(root, headers) {
return new Promise((resolve) => {
const srv = http.createServer((req, res) => {
let p = decodeURIComponent(new URL(req.url, "http://local").pathname);
if (p.endsWith("/")) p += "index.html";
const file = path.join(root, p);
if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, { "Content-Type": mime[path.extname(file)] || "application/octet-stream", ...headers });
fs.createReadStream(file).pipe(res);
});
srv.listen(0, "127.0.0.1", () => resolve(srv));
});
}
async function run(browser, label, headers, wantShared, depth = 0) {
const srv = await serve(site, headers);
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
page.on("pageerror", (e) => fail(`${label}: page error: ${e.message}`));
const { js, until } = await scriptWithoutGesture(page);
const stats = async () => JSON.parse(await js("globalThis.aegoAudio.stats()"));
try {
await page.goto(`http://127.0.0.1:${srv.address().port}/play/`);
await until("!!(globalThis.aegoAudio && globalThis.aegoAudio.start)", 120000);
const isolated = await js("globalThis.crossOriginIsolated === true");
if (isolated !== wantShared) fail(`${label}: crossOriginIsolated is ${isolated}`);
const enforced = await js("(async () => { const c = new AudioContext(); await new Promise((r) => setTimeout(r, 300)); const s = c.state; await c.close(); return s !== 'running'; })()");
if (depth) await js(`globalThis.aegoAudioBlocks = ${depth}`);
const err = await js("globalThis.aegoAudio.start()");
if (err) throw new Error("start: " + err);
await sleep(500);
const before = await stats();
if (!enforced) console.log(`${label}: this browser starts an AudioContext without a gesture, the gesture check is skipped`);
else if (before.running || before.queued !== 0) fail(`${label}: audio ran before a gesture: ${JSON.stringify(before)}`);
await page.mouse.click(5, 5);
await until("JSON.parse(globalThis.aegoAudio.stats()).running", 10000);
await sleep(seconds * 1000);
const s = await stats();
if (s.error) fail(`${label}: ${s.error}`);
if (s.shared !== wantShared) fail(`${label}: shared transport is ${s.shared}`);
if (Math.abs(s.rms - 0.354) > 0.05) fail(`${label}: rms ${s.rms}`);
if (!Number.isInteger(s.targetBlocks) || s.targetBlocks <= 0 || !Number.isInteger(s.blockFrames) || s.blockFrames <= 0) {
throw new Error(`invalid actual audio target: ${JSON.stringify(s)}`);
}
const used = s.targetBlocks;
if (depth && used !== depth) fail(`${label}: requested ${depth} blocks but the pump uses ${used}`);
console.log(`${label}: rate ${s.rate}, shared ${s.shared}, ${used} blocks (${((used * s.blockFrames * 1000) / s.rate).toFixed(0)} ms), underruns ${s.underruns} (pump ${s.pumpUnderruns}) in ${seconds} s, rms ${s.rms.toFixed(3)}, mix max ${s.mixMaxUs} us`);
if (!Number.isInteger(s.pumpUnderruns) || s.pumpUnderruns < 0) fail(`${label}: invalid pump underrun count`);
if (!Number.isInteger(s.underruns) || s.underruns < 0) fail(`${label}: invalid worklet underrun count`);
if (s.underruns > 0) fail(`${label}: ${s.underruns} underruns in ${seconds} s at ${used} blocks`);
if (s.pumpUnderruns > 0) fail(`${label}: ${s.pumpUnderruns} predicted pump underruns in ${seconds} s at ${used} blocks`);
await js("globalThis.aegoAudio.stop()");
} catch (e) {
fail(`${label}: ${e.message}`);
} finally {
await page.close();
srv.close();
}
}
async function demo(browser, label, headers, wantShared) {
if (!fs.existsSync(path.join(site, "demo", "index.html"))) {
fail(`${label}: site/demo is missing`);
return;
}
const srv = await serve(site, headers);
const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
page.on("pageerror", (e) => fail(`${label}: page error: ${e.message}`));
const { js, until } = await scriptWithoutGesture(page);
const stats = async () => JSON.parse(await js("globalThis.aegoAudioStats()"));
const base = `http://127.0.0.1:${srv.address().port}/demo/`;
try {
await page.goto(base);
await until("globalThis.aegoBoot !== undefined && typeof globalThis.aegoAudioStats === 'function'", 120000);
const quiet = await js("document.documentElement.dataset.sound === 'off'");
if (quiet && !(await stats()).muted) fail(`${label}: the page asks for silence, but the demo starts with sound`);
await js("globalThis.aegoAudioMute(false)");
const enforced = await js(`(async () => {
const c = new AudioContext();
await new Promise((r) => setTimeout(r, 300));
const s = c.state;
await c.close();
return s !== "running";
})()`);
const before = await stats();
const activated = await js("navigator.userActivation && navigator.userActivation.hasBeenActive");
if (activated) fail(`${label}: the harness activated the page before the real click`);
console.log(`${label}: before click running ${before.running}, underruns ${before.underruns}, consumed ${before.consumed}, page activated ${!!activated}`);
if (!enforced) console.log(`${label}: this browser starts an AudioContext without a gesture, the gesture check is skipped`);
else if (before.running) fail(`${label}: audio ran before a gesture`);
await page.click("#game");
await sleep(demoSeconds * 1000);
const s = await stats();
if (s.error) fail(`${label}: ${s.error}`);
if (s.shared !== wantShared) fail(`${label}: shared transport is ${s.shared}`);
if (!Number.isInteger(s.underruns) || s.underruns < 0) fail(`${label}: invalid worklet underrun count`);
const pump = await js("globalThis.aegoGame?.mixStats?.() ?? null");
if (!pump || !Number.isInteger(pump.underruns) || pump.underruns < 0) fail(`${label}: invalid game pump underrun count`);
else if (pump.underruns > 0) fail(`${label}: ${pump.underruns} predicted pump underruns in the game`);
if (s.underruns > 0) fail(`${label}: ${s.underruns} underruns in the game, which fills audio from the frame loop`);
if (!s.running || s.rms < 0.01) fail(`${label}: music is silent: ${JSON.stringify(s)}`);
console.log(`${label}: rate ${s.rate}, shared ${s.shared}, rms ${s.rms.toFixed(3)}, underruns ${s.underruns} (pump ${pump?.underruns})`);
if (await page.isHidden("#mute")) fail(`${label}: the mute button is hidden`);
await page.click("#mute");
await sleep(300);
const m = await stats();
if (!m.muted) fail(`${label}: mute did not apply`);
if (m.out !== 0) fail(`${label}: muted output is not silent: ${JSON.stringify(m)}`);
await page.reload();
await until("typeof globalThis.aegoAudioStats === 'function'", 120000);
if (!(await stats()).muted) fail(`${label}: mute was not remembered`);
await js("globalThis.aegoAudioMute(false)");
} catch (e) {
fail(`${label}: ${e.message}`);
} finally {
await page.close();
srv.close();
}
}
const browser = await chromium.launch({
executablePath,
headless: opts.headed !== "true",
args: ["--mute-audio", ...(opts.gpu === "hard" ? [] : ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"]), "--ignore-gpu-blocklist", "--autoplay-policy=user-gesture-required"],
});
try {
await run(browser, "messages", {}, false);
for (const b of blocks) await run(browser, `messages at ${b} blocks`, {}, false, b);
await run(browser, "shared", isolate, true);
await demo(browser, "demo", {}, false);
await demo(browser, "demo shared", isolate, true);
} finally {
await browser.close();
}
if (failures.length) {
console.error(`${failures.length} failure(s)`);
process.exit(1);
}
console.log("web audio: ok");