From fb17ba703c5ba369d389f0f2ed3e87acc018e646 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 12 Jul 2026 16:26:59 -0700 Subject: [PATCH] pads: adaptive-quality controller + fps certification probe - lib/pads.mjs: times each onPaint, exposes ctx.quality (1->0.35), auto-cuts over the 16.7ms budget + restores with headroom; logs [pads:fps] from the render worker for certification. - marketing/av-reels/bin/measure-fps.mjs: frame-less headless probe reading [pads:fps] to certify real render fps + settled quality (disk-safe, no frames). --- marketing/av-reels/bin/measure-fps.mjs | 92 +++++++++++++++++++ system/public/aesthetic.computer/lib/pads.mjs | 55 +++++++++++ 2 files changed, 147 insertions(+) create mode 100644 marketing/av-reels/bin/measure-fps.mjs diff --git a/marketing/av-reels/bin/measure-fps.mjs b/marketing/av-reels/bin/measure-fps.mjs new file mode 100644 index 0000000000..fb2e3e0993 --- /dev/null +++ b/marketing/av-reels/bin/measure-fps.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// measure-fps.mjs — certify a PAD's real render fps, headless, WITHOUT writing +// frames (disk-safe). lib/pads.mjs logs `[pads:fps] q= work=` +// from the render worker (~every 120 frames); this probe reads those console +// lines over a window and reports avg / min / and the settled quality. +// +// node marketing/av-reels/bin/measure-fps.mjs prism --base http://localhost:8899 +// node .../measure-fps.mjs molten --base http://localhost:8899 --secs 12 --density 1 +// +// Flags: --base URL --secs N (sample window after warmup, default 10) +// --warmup N (default 3) --density N (default 1 = native, hardest) --w --h +import { existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const argv = process.argv.slice(2); +const flags = {}; +let PIECE = null; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith("--")) flags[a.slice(2)] = argv[i + 1]?.startsWith("--") || i + 1 >= argv.length ? true : argv[++i]; + else if (!PIECE) PIECE = a; +} +if (!PIECE) { console.error("usage: measure-fps.mjs '' [--base URL] [--secs 10] [--density 1]"); process.exit(1); } + +const BASE = flags.base || "https://aesthetic.computer"; +const SECS = parseFloat(flags.secs || "10"); +const WARMUP = parseFloat(flags.warmup || "3"); +const DENSITY = flags.density || "1"; +const W = parseInt(flags.w || "432", 10); +const H = parseInt(flags.h || "768", 10); + +const PUPPETEER_DIR = [ + `${REPO}/node_modules/puppeteer`, `${REPO}/oven/node_modules/puppeteer`, "/opt/oven/node_modules/puppeteer", +].find((p) => existsSync(p)); +if (!PUPPETEER_DIR) throw new Error("puppeteer not found"); +const puppeteer = (await import(`${PUPPETEER_DIR}/lib/esm/puppeteer/puppeteer.js`)).default; +const CHROME = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", +].find((p) => p && existsSync(p)); + +const url = `${BASE}/${encodeURIComponent(PIECE)}?nolabel&nogap&density=${DENSITY}`; +const browser = await puppeteer.launch({ + headless: "new", + ...(CHROME ? { executablePath: CHROME } : {}), + args: ["--no-sandbox", "--autoplay-policy=no-user-gesture-required", + "--use-gl=angle", "--use-angle=metal", "--enable-gpu", "--ignore-gpu-blocklist", + "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", "--disable-features=CalculateNativeWinOcclusion", + `--window-size=${W},${H}`], +}); +const page = await browser.newPage(); +await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 }); +let pageErr = 0; +page.on("pageerror", () => pageErr++); + +let collecting = false; +const fps = []; +const qual = []; +const RE = /\[pads:fps\]\s+([\d.]+)\s+q=([\d.]+)/; +const onLine = (t) => { + const m = RE.exec(t); + if (m && collecting) { fps.push(parseFloat(m[1])); qual.push(parseFloat(m[2])); } +}; +page.on("console", (m) => onLine(m.text())); +// Worker console may arrive on a worker target rather than the page. +page.on("workercreated", (w) => w.on("console", (m) => onLine(m.text()))); + +try { await page.goto(url, { waitUntil: "networkidle2", timeout: 45000 }); } +catch (e) { console.log(`⚠ goto: ${e.message.slice(0, 70)}`); } + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await sleep(WARMUP * 1000); +collecting = true; +await sleep(SECS * 1000); +await browser.close(); + +if (fps.length === 0) { + console.log(`${PIECE.padEnd(12)} ⚠ no [pads:fps] samples (not a pads.mjs pad? or console not surfaced) · errs=${pageErr}`); + process.exit(0); +} +fps.sort((a, b) => a - b); +const avg = fps.reduce((a, b) => a + b, 0) / fps.length; +const min = fps[0]; +const q = qual.length ? qual[qual.length - 1] : 1; // settled quality +const cert = min >= 58 ? "✅ 60fps" : min >= 45 ? "⚠ 45-60" : "❌ <45"; +console.log( + `${PIECE.padEnd(12)} ${cert} avg=${avg.toFixed(0)} min=${min.toFixed(0)} fps` + + ` q=${q.toFixed(2)} n=${fps.length} density=${DENSITY} errs=${pageErr}`, +); diff --git a/system/public/aesthetic.computer/lib/pads.mjs b/system/public/aesthetic.computer/lib/pads.mjs index 8de979573c..4662f4bfbf 100644 --- a/system/public/aesthetic.computer/lib/pads.mjs +++ b/system/public/aesthetic.computer/lib/pads.mjs @@ -17,6 +17,12 @@ // once and handed to every hook via ctx. // • RICH ZERO-LATENCY VOICES — see `voices` below (harp/flute/bubble physical // models + ADSR + layered detune), so pads sound full without sample latency. +// • ADAPTIVE QUALITY (auto-scale to hold ~60fps) — the engine times each onPaint +// and exposes `ctx.quality` (1 = full detail … 0.35 = auto-cut). READ it and +// scale your cost: per-pixel pads stride the loop / shrink the offscreen buffer +// (`step = quality<0.6 ? 2 : 1`), particle pads scale counts +// (`n = Math.round(BASE * quality)`), feedback pads drop blur/veil when low. +// Pads that ignore it always render full detail (fine for cheap vector pads). // // Wrapper skeleton (initPad MUST run in boot, not at import — this module is a // session singleton shared by every pad, so each entry must re-assert its config @@ -40,6 +46,24 @@ let bands = []; let amp = 0; let beatHit = 0; +// —— adaptive quality (auto-scale to hold ~60fps) —— +// The engine times each onPaint and nudges a `quality` scalar (1 = full detail, +// down to 0.35) with hysteresis. Pads READ ctx.quality and cut cost: per-pixel +// pads stride/shrink their buffer, particle pads scale counts, feedback pads +// skip blur/veil. AC's paint is rAF-driven (60fps display cap), so we target the +// onPaint WORK time (not frame cadence, which vsync pins) against the ~16.7ms +// budget — leaving margin for sim + compositing. +let quality = 1; +let workEMA = 8; // ms, smoothed onPaint work time +let qAdjust = 0; // frames since last quality nudge +let lastPaintAt = 0; // perfNow of previous paint (for fps) +let fpsEMA = 60; // smoothed render fps (paint cadence) +let fpsLog = 0; // frames since last fps log line +const perfNow = + typeof performance !== "undefined" && performance.now + ? () => performance.now() + : () => Date.now(); + export function initPad(config) { cfg = { bpm: 120, steps: 16, hooks: {}, ...config }; cfg.hooks = config.hooks || {}; @@ -55,6 +79,12 @@ function reset() { bands = []; amp = 0; beatHit = 0; + quality = 1; + workEMA = 8; + qAdjust = 0; + lastPaintAt = 0; + fpsEMA = 60; + fpsLog = 0; } const band = (name) => @@ -73,6 +103,7 @@ function ctx(extra) { band, // band("subBass"|"lowMid"|"mid"|"air"|…) bands, simMs, + quality, // 1 = full detail … 0.35 = auto-cut for perf (READ this to scale cost) ...extra, }; } @@ -129,6 +160,20 @@ export function sim(api) { } export function paint(api) { + const t0 = perfNow(); + // render fps = paint-call cadence (this runs once per rendered frame). The + // heavier onPaint is, the further apart these calls land → fps drops. + if (lastPaintAt) { + const dt = t0 - lastPaintAt; + if (dt > 0 && dt < 1000) fpsEMA += (1000 / dt - fpsEMA) * 0.1; + } + lastPaintAt = t0; + if (++fpsLog >= 120) { + fpsLog = 0; + try { + console.log(`[pads:fps] ${fpsEMA.toFixed(1)} q=${quality.toFixed(2)} work=${workEMA.toFixed(1)}ms`); + } catch (e) {} + } cfg.hooks.onPaint?.(api, ctx()); // Shared tap-burst overlay: expanding hollow rings at each tap, unless the // wrapper opted to draw bursts itself (drawBursts:false). @@ -139,6 +184,16 @@ export function paint(api) { api.ink(r, g, bl, a).circle(b.x, b.y, b.r, false); } } + // Adaptive quality: keep onPaint work inside the 60fps budget. Smooth the work + // time and nudge `quality` every ~24 frames with a deadband so it doesn't + // oscillate. Pads that ignore ctx.quality simply always render full detail. + const work = perfNow() - t0; + workEMA += (work - workEMA) * 0.12; + if (++qAdjust >= 24) { + qAdjust = 0; + if (workEMA > 12) quality = Math.max(0.35, quality - 0.06); // over budget → cut + else if (workEMA < 7) quality = Math.min(1, quality + 0.03); // headroom → restore + } } export function act(api) { -- 2.51.2