    /* the envelope at the width the strip draws: 64 columns, each the
       loudest frame in its slice — the shape of the singing, not the wave */
    const cols = $derived.by(() => {
        const f = tapSync.activity?.frames;
        if (!f?.length) return [];
        const per = f.length / 64;
        return Array.from({ length: 64 }, (_, i) => {
            let m = 0;
            for (let j = Math.floor(i * per); j < Math.floor((i + 1) * per) && j < f.length; j++) m = Math.max(m, f[j]);
            return m;
        });
    });
    const spanMs = $derived((tapSync.activity?.frames?.length ?? 0) * (tapSync.activity?.frameMs ?? 100));
    const pct = (ms) => (spanMs ? Math.max(0, Math.min(100, (ms / spanMs) * 100)) : 0);
    /* one button, four states: reading the file, nothing worth reading, the
       estimate, and the way back out of it */
    const estLabel = $derived(
        tapSync.reading === "ready"
            ? (tapSync.estimated ? "Clear the estimate" : "Estimate the timing")
            : tapSync.reading === "none" ? "No estimate for this one" : "Reading the song…",
    );
