diff --git a/CHANGELOG.md b/CHANGELOG.md index a301a03..5de4133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 next root-level module can't ship broken. ### Added +- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`. - **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. - **`routers/` — the first extracted route module (R3).** The five audio-effects mapping endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a diff --git a/docs/perf-baseline.md b/docs/perf-baseline.md index b0d0b0f..5883fd5 100644 --- a/docs/perf-baseline.md +++ b/docs/perf-baseline.md @@ -46,7 +46,7 @@ highway actually painted count (the other ~half are cheap no-op loops that would otherwise mask a regression). Any `highway.js` change must re-run this on the same machine and stay within noise of these numbers. -``` +```bash node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 \ --song "Arcturus - The Sham Mirrors - Kinetic.feedpak" ``` diff --git a/scripts/perf-baseline.mjs b/scripts/perf-baseline.mjs index edf7d4d..92db68f 100644 --- a/scripts/perf-baseline.mjs +++ b/scripts/perf-baseline.mjs @@ -85,35 +85,41 @@ async function clientMetrics() { // ── 2D highway per-frame draw cost (needs --song + a seeded library) ────────── async function frameTimeOnce(browser) { const page = await browser.newPage(); - // Wrap rAF before any page script; mark frames the highway actually drew. - await page.addInitScript(() => { - window.__f = []; - window.__drew = false; - const raf = window.requestAnimationFrame.bind(window); - window.requestAnimationFrame = (cb) => raf((t) => { + let f, t2, notes; + try { + // Wrap rAF before any page script; mark frames the highway actually drew. + await page.addInitScript(() => { + window.__f = []; window.__drew = false; - const t0 = performance.now(); - try { cb(t); } finally { window.__f.push({ ms: performance.now() - t0, drew: window.__drew }); } + const raf = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = (cb) => raf((t) => { + window.__drew = false; + const t0 = performance.now(); + try { cb(t); } finally { window.__f.push({ ms: performance.now() - t0, drew: window.__drew }); } + }); }); - }); - await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 }); - await page.evaluate(() => document.getElementById('v3-onboarding')?.remove()); - await page.evaluate((s) => window.playSong(s), SONG); - await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length >= 0 && window.highway?.getSongInfo?.(), - null, { timeout: 45000 }).catch(() => {}); - await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length > 0, null, { timeout: 45000 }); - await page.evaluate(() => window.highway.addDrawHook(() => { window.__drew = true; })); - // Start playback so draw() leaves its paused-throttle path; confirm the clock advances. - await page.evaluate(async () => { const a = window.highway.getAudioElement?.(); if (a) a.muted = true; await a?.play?.(); }); - await page.waitForTimeout(1500); - const t1 = await page.evaluate(() => window.highway.getTime()); - await page.evaluate(() => { window.__f.length = 0; }); - await page.waitForTimeout(FRAME_S * 1000); - const { f, t2, notes } = await page.evaluate(() => ({ - f: window.__f.slice(), t2: window.highway.getTime(), notes: window.highway.getNotes().length, - })); - await page.close(); - if (!(t2 > t1 + 1)) throw new Error(`clock did not advance (${t1}→${t2}) — measured the paused path`); + await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 }); + await page.evaluate(() => document.getElementById('v3-onboarding')?.remove()); + await page.evaluate((s) => window.playSong(s), SONG); + await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length >= 0 && window.highway?.getSongInfo?.(), + null, { timeout: 45000 }).catch(() => {}); + await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length > 0, null, { timeout: 45000 }); + await page.evaluate(() => window.highway.addDrawHook(() => { window.__drew = true; })); + // Start playback so draw() leaves its paused-throttle path; confirm the clock advances. + await page.evaluate(async () => { const a = window.highway.getAudioElement?.(); if (a) a.muted = true; await a?.play?.(); }); + await page.waitForTimeout(1500); + const t1 = await page.evaluate(() => window.highway.getTime()); + await page.evaluate(() => { window.__f.length = 0; }); + await page.waitForTimeout(FRAME_S * 1000); + ({ f, t2, notes } = await page.evaluate(() => ({ + f: window.__f.slice(), t2: window.highway.getTime(), notes: window.highway.getNotes().length, + }))); + if (!(t2 > t1 + 1)) throw new Error(`clock did not advance (${t1}→${t2}) — measured the paused path`); + } finally { + // Always close, even when an await above throws — otherwise a failing + // run leaks its page until the final browser.close(). + await page.close(); + } const drew = f.filter((x) => x.drew).map((x) => x.ms); return { drew, total: f.length, notes }; }