Add a ?pi=1 profile, and stop re-deriving search keys per keystroke

Two separate costs, both measured on musicdolphin (Pi 4, 1920x1080 kiosk), where the
app was burning ~70% of a core with nothing happening on screen.

Per-frame work. The ambient canvas repaints a full-screen gradient plus a particle
field every frame, and `usePlaybackClock` pushes a React setState per animation frame
into both PlayView and PlayerBar for the whole length of a track. `?pi=1` (lib/
lowPower.ts) makes those cheaper rather than switching them off: the canvas paints a
quarter of the pixels at 30fps with a bubble cap, and the clock renders ten times a
second - a progress bar advances one pixel every few hundred ms and its label has
one-second resolution, so nothing on screen can tell. Only the effects with no cheap
version actually go: the backdrop-filter glass blur and the decorative CSS loops.
Also drops a redundant full-canvas clearRect that the opaque gradient always covered.

Search. normalize() runs a Unicode NFD decomposition, and albumMatches/songMatches
called it on every album title and every track title on every keystroke - 4969 of them
for a track search, whose answer cannot change until the library does. buildSearchIndex
does it once per library payload; a keystroke is now String.includes over strings that
already exist. On the real library that is 33ms -> 3.4ms for an eight-letter track
query on a laptop, and this runs on a Pi. The same index partitions albums by shelf and
pre-sorts each shelf's categories, which App and BrowseView were deriving separately
from the same data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 13:15:23 +02:00
parent a7fb56c9fe
commit cc3db44c4e
11 changed files with 457 additions and 67 deletions

View File

@@ -41,6 +41,7 @@ import {
type Oklch,
} from "../lib/ambience";
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
import { AMBIENCE_QUALITY } from "../lib/lowPower";
import { groupOf, type Group } from "../lib/search";
/** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to,
@@ -247,16 +248,22 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
// can't feed back into the size it just measured (see the comment there). The
// clamp is a second line of defence against the same failure mode from anywhere
// else: an unclamped size can hit the browser's canvas allocation limit and throw.
const MAX_BACKING_STORE_PX = 4096;
//
// `resolutionScale` below 1 (the `?pi=1` profile) makes the backing store smaller
// than the canvas is on screen and lets the browser scale it back up. Everything
// in `tick` still works in CSS-pixel units, because the transform is derived from
// whatever the backing store ended up being rather than from `dpr`.
const quality = AMBIENCE_QUALITY;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
const dpr = (window.devicePixelRatio || 1) * quality.resolutionScale;
const rect = canvas.getBoundingClientRect();
size.width = rect.width;
size.height = rect.height;
canvas.width = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.width * dpr)));
canvas.height = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.height * dpr)));
// Scale factors from the (possibly clamped) backing store, not raw `dpr`, so
// drawing in CSS-pixel units still lands correctly even when clamped.
const cap = quality.maxBackingStorePx;
canvas.width = Math.min(cap, Math.max(1, Math.round(rect.width * dpr)));
canvas.height = Math.min(cap, Math.max(1, Math.round(rect.height * dpr)));
// Scale factors from the (possibly clamped, possibly downscaled) backing store,
// not raw `dpr`, so drawing in CSS-pixel units still lands correctly.
const scaleX = canvas.width / Math.max(1, rect.width);
const scaleY = canvas.height / Math.max(1, rect.height);
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
@@ -312,7 +319,16 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
smoothedDrive,
);
// A frame budget, not a timer: rAF still drives everything, this just returns
// early until enough time has passed. The 1ms slack stops a 30fps budget from
// landing just inside a 60Hz vsync interval and silently halving again to 20.
const minFrameMs = quality.maxFps > 0 ? 1000 / quality.maxFps - 1 : 0;
const tick = (now: number) => {
if (now - lastFrame < minFrameMs) {
raf = requestAnimationFrame(tick);
return;
}
const dt = Math.min(MAX_FRAME_SECONDS, (now - lastFrame) / 1000);
lastFrame = now;
@@ -386,6 +402,9 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
spawnAccumulator += (current.spawnRate + kick * BEAT_BURST_RATE) * dt;
while (spawnAccumulator >= 1) {
spawnAccumulator -= 1;
// The cap drops the *newest* bubble rather than evicting an old one, so a
// loud passage thins the field out instead of making it flicker.
if (quality.maxBubbles > 0 && bubbles.length >= quality.maxBubbles) break;
bubbles.push(spawnBubble(size.width));
}
}
@@ -402,7 +421,9 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
return b.risen < size.height + BUBBLE_MAX_SIZE * 2;
});
ctx.clearRect(0, 0, size.width, size.height);
// No `clearRect`: the gradient below is fully opaque (`oklchString` defaults to
// alpha 1) and covers the whole canvas, so clearing first is a second
// full-screen write per frame that nothing can ever see.
const [top, mid, deep] = current.gradient;
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
gradient.addColorStop(0, oklchString(top));

View File

@@ -18,7 +18,7 @@ import {
unitLabel,
} from "../lib/covers";
import { clock } from "../lib/format";
import type { Category, Group, Results, SongHit } from "../lib/search";
import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search";
import { categoryMatches, groupOf } from "../lib/search";
import {
ANIMATE_VIEW_TRANSITIONS,
@@ -37,7 +37,7 @@ import { Cover } from "./Cover";
interface Props {
results: Results;
group: Group | null;
albums: Album[];
index: SearchIndex;
mode: "albums" | "tracks";
search: string;
category: string | null;
@@ -76,7 +76,7 @@ const GROUP_SECTION_LABEL: Record<Group, string> = {
export function BrowseView({
results,
group,
albums,
index,
mode,
search,
category,
@@ -114,14 +114,14 @@ export function BrowseView({
GROUPS.map((shelfGroup) => ({
group: shelfGroup,
categories: categoryMatches({
albums,
index,
search: "",
mode: "albums",
category: null,
group: shelfGroup,
}),
})),
[albums],
[index],
);
// Keep the selection on screen as the arrow keys walk past the fold.