Files
SoulStream/tests/mobile/perf.mjs
KeelrLeek bb828ff2dc android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour
The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.

Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.

The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.

The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
2026-09-13 09:30:06 -05:00

156 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Where the phone's frames go.
Attaches to the app on the connected phone, then times the frames the
WebView actually produced (a requestAnimationFrame log in the page) while
the harness scrolls a long list, pushes a record page, opens the player
and sits idle with a track playing — the moments a person feels as lag.
Each scene runs under several CSS variants injected live, so the cost of
an effect is measured rather than guessed: the backdrop blurs behind the
chrome, the artwork ambience, the entrance animations.
node tests/mobile/perf.mjs every scene × every variant
node tests/mobile/perf.mjs --quick the base variant only
Frames over 32 ms are two dropped frames at 60 Hz; the p90 says how the
scene feels, the max how bad the worst hitch was. */
import { attachPhone, sleep } from "./cdp.mjs";
const QUICK = process.argv.includes("--quick");
const PORT = 9222;
const VARIANTS = {
base: "",
"no-backdrop": `.nav,.mini,.gbar,.halfsheet{backdrop-filter:none!important;-webkit-backdrop-filter:none!important}`,
"no-ambience": `.amb{filter:none!important}.grain{display:none!important}`,
"no-feat-blur": `.feat .bg{filter:none!important}`,
"no-motion": `.screen,.screen>*{animation:none!important}`,
"no-blur-at-all": `.nav,.mini,.gbar,.halfsheet{backdrop-filter:none!important;-webkit-backdrop-filter:none!important}.amb{filter:none!important}.grain{display:none!important}.feat .bg{filter:none!important}`,
};
const FRAMES = `(function () {
if (!window.__fm) {
var f = { t: [], on: false };
window.__fm = f;
(function loop(ts) { if (f.on) f.t.push(ts); requestAnimationFrame(loop); })(0);
}
return true;
})()`;
const START = `(__fm.t = [], __fm.on = true, true)`;
const STOP = `(function () {
__fm.on = false;
var t = __fm.t, d = [];
for (var i = 1; i < t.length; i++) d.push(t[i] - t[i - 1]);
d.sort(function (a, b) { return a - b; });
var n = d.length;
if (!n) return { frames: 0 };
var q = function (p) { return Math.round(d[Math.min(n - 1, Math.floor(n * p))]); };
return { frames: n, p50: q(0.5), p90: q(0.9), p99: q(0.99), max: Math.round(d[n - 1]),
over32: d.filter(function (x) { return x > 32; }).length, over100: d.filter(function (x) { return x > 100; }).length };
})()`;
const VARIANT = (css) => `(function () {
var s = document.getElementById('__perfvar');
if (!s) { s = document.createElement('style'); s.id = '__perfvar'; document.head.appendChild(s); }
s.textContent = ${JSON.stringify(css)};
return true;
})()`;
async function scrollGesture(cdp, distance) {
/* the WebView's own scroll, through the input pipeline, not scrollBy */
const size = await cdp.evaluate(`[innerWidth, innerHeight]`);
await cdp.send("Input.synthesizeScrollGesture", {
x: Math.round(size[0] / 2), y: Math.round(size[1] * 0.6),
yDistance: distance, speed: 1400, gestureSourceType: "touch",
});
}
/* the handle on the page has no patch(); tabs are clicked like a thumb would */
const tab = (i) => `(function(){ var b = document.querySelectorAll('.segtabs button')[${i}]; if (b) b.click(); return true; })()`;
const SCENES = [
{
name: "scroll tracks",
setup: async (cdp) => { await cdp.evaluate(`__ss.go('library'); true`); await sleep(300); await cdp.evaluate(tab(2)); },
run: async (cdp) => { await scrollGesture(cdp, -1800); await scrollGesture(cdp, 1800); await scrollGesture(cdp, -1800); },
},
{
name: "scroll home",
setup: async (cdp) => { await cdp.evaluate(`__ss.go('home'); true`); },
run: async (cdp) => { await scrollGesture(cdp, -1200); await scrollGesture(cdp, 1200); },
},
{
name: "push record",
setup: async (cdp) => { await cdp.evaluate(`__ss.go('library'); true`); await sleep(300); await cdp.evaluate(tab(0)); },
run: async (cdp) => {
await cdp.evaluate(`(function(){ var c = document.querySelector('.grid2 .release'); if (c) c.click(); })()`);
await sleep(900);
await cdp.evaluate(`__ss.back(); true`);
await sleep(700);
},
},
{
name: "open player",
setup: async (cdp) => { await cdp.evaluate(`__ss.go('home'); true`); },
run: async (cdp) => {
await cdp.evaluate(`(function(){ var m = document.querySelector('.mini'); if (m) m.click(); })()`);
await sleep(1100);
await cdp.evaluate(`__ss.closeSheet(); true`);
await sleep(700);
},
},
{
name: "idle, playing",
setup: async (cdp) => { await cdp.evaluate(`__ss.go('home'); true`); },
run: async () => { await sleep(3000); },
},
];
async function main() {
const { cdp } = await attachPhone(PORT);
await cdp.send("Runtime.enable");
await cdp.send("Performance.enable");
const hasHandle = await cdp.evaluate(`!!window.__ss`);
if (!hasHandle) throw new Error("no __ss handle on the page — an older build?");
await cdp.evaluate(FRAMES);
/* something must be playing for the lane and the ambience to cost anything */
if (!(await cdp.evaluate(`__ss.player.status === 'playing'`))) {
/* something must be playing for the lane and the ambience to cost anything */
await cdp.evaluate(`__ss.go('library'); true`); await sleep(300); await cdp.evaluate(tab(0)); await sleep(300);
await cdp.evaluate(`(function(){ var c = document.querySelector('.grid2 .release'); if (c) c.click(); })()`); await sleep(900);
await cdp.evaluate(`(function(){ var d = document.querySelector('.rec-acts .disc'); if (d) d.click(); })()`); await sleep(1500);
await cdp.evaluate(`__ss.back(); true`); await sleep(500);
}
const variants = QUICK ? ["base"] : Object.keys(VARIANTS);
const rows = [];
for (const scene of SCENES) {
for (const v of variants) {
await cdp.evaluate(VARIANT(VARIANTS[v]));
await scene.setup(cdp);
await sleep(900);
const m0 = (await cdp.send("Performance.getMetrics")).result.metrics;
await cdp.evaluate(START);
await scene.run(cdp);
const stats = await cdp.evaluate(STOP);
const m1 = (await cdp.send("Performance.getMetrics")).result.metrics;
const metric = (name) => (m1.find((x) => x.name === name)?.value ?? 0) - (m0.find((x) => x.name === name)?.value ?? 0);
const row = {
scene: scene.name, variant: v, ...stats,
script: Math.round(metric("ScriptDuration") * 1000),
style: Math.round(metric("RecalcStyleDuration") * 1000),
layout: Math.round(metric("LayoutDuration") * 1000),
task: Math.round(metric("TaskDuration") * 1000),
};
rows.push(row);
console.log(
scene.name.padEnd(14) + v.padEnd(16)
+ `frames ${String(row.frames).padStart(4)} p50 ${String(row.p50).padStart(3)} p90 ${String(row.p90).padStart(3)} p99 ${String(row.p99).padStart(4)} max ${String(row.max).padStart(4)} >32ms ${String(row.over32).padStart(3)} >100ms ${String(row.over100).padStart(2)}`
+ ` script ${row.script}ms style ${row.style}ms layout ${row.layout}ms`,
);
}
}
await cdp.evaluate(VARIANT(""));
await cdp.evaluate(`__ss.go('home'); true`);
cdp.close();
return rows;
}
main().catch((e) => { console.error(e.message || e); process.exit(1); });