notepat: massive FPS boost — QR cache + C-side waveform strip
Two hot paths were costing most of the per-frame budget on slow Intel
m3 class CPUs (the ThinkPad this runs on). Moving both into C brings
notepat's paint path back into single-digit milliseconds.
1. graph_qr() — now caches the last qrcodegen_encodeText() result keyed
by the input URL. Previously the QR was re-encoded (Reed-Solomon +
masking) every single paint frame; the cache turns that into a
single strncmp and a cheap module-grid walk. `notepat.com` never
changes so the cache is effectively permanent.
2. sound.speaker.drawStrip(x, y, w, h, seconds, needleFrac, flags)
renders the whole scrolling DJ-needle waveform in one C call:
- reads the speaker ring buffer under audio->lock, copies only the
slice we need (no Float32Array allocation handed to JS)
- per-column peak-scan runs in native int/float math, not QuickJS
interpreted loops
- background, zero-line, per-column warm→cold bar, and the playhead
needle are drawn in-place via graph_* primitives
Previously notepat did `sound.speaker.getRecentBuffer(4)` every 4
frames (malloc + ring copy + new Float32Array into JS), then a 600-
iteration JS for-loop per paint that called Math.floor/abs/round
and ink()+line() 600 times. On a dual-core m3-8100Y that was pinning
FPS at ~20.
New shape keeps it well under 1ms per frame and renders every paint
(no more 4-frame staleness).
Concurrent behavior changes (requested by @jeffrey):
* Playhead needle moves from the right edge to the CENTER of the strip
(needleFrac=0.5) — reads like a turntable stylus, not a VU capture.
* When spacebar is held, the reverse-scroll flag flips so the waveform
visually moves the opposite direction: the held key now reads as a
"rewind/replay cursor" rather than live forward capture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>