Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

+163 -55
+19 -45
fedac/native/pieces/notepat.mjs
··· 4138 4138 const rightX = w - gridW - margin; 4139 4139 4140 4140 // Scrolling record-needle strip: the last ~4 seconds of mixed speaker 4141 - // output, always rolling regardless of whether notes are active. Think 4142 - // classic DJ turntable display — you can see the waveform the spacebar 4143 - // reverse-play would snap back into. Refreshed every 4 frames to keep 4144 - // paint cheap; downsampled to one peak value per pixel column. 4141 + // output, always rolling. Classic DJ-turntable display with the playhead 4142 + // pinned at CENTER (left half = past that just played, right half = 4143 + // about-to-be-replaced samples waiting to scroll out). 4144 + // 4145 + // Rendered in a single C call (sound.speaker.drawStrip) which does: 4146 + // - background + zero-line 4147 + // - per-pixel-column peak scan on the speaker ring (under audio lock) 4148 + // - warm→cold color ramp 4149 + // - needle at needleFrac 4150 + // Previously this was a 600-iteration JS loop + getRecentBuffer copy 4151 + // every 4 frames; the C path runs every frame at negligible cost. 4152 + // 4153 + // When spacebar is held the `reverse` flag flips so the waveform appears 4154 + // to scroll the OPPOSITE direction — treating the held-space as a 4155 + // backwards-replay cursor rather than a live capture. 4145 4156 const recordStripH = 22; 4146 4157 const recordStripSeconds = 4; 4147 4158 const recordStripTop = Math.max(topBarH + 1, gridTop - recordStripH - 2); 4148 - const recordStripBottom = recordStripTop + recordStripH; 4149 - if (frame % 4 === 0 && sound?.speaker?.getRecentBuffer) { 4150 - const snap = sound.speaker.getRecentBuffer(recordStripSeconds); 4151 - if (snap && snap.data && snap.data.length > 0) { 4152 - globalThis.__recordStripData = snap.data; 4153 - } 4154 - } 4155 - const rsData = globalThis.__recordStripData; 4156 - if (rsData && rsData.length > 4) { 4159 + if (sound?.speaker?.drawStrip) { 4157 4160 const rsX = margin; 4158 4161 const rsW = w - margin * 2; 4159 - const midY = Math.floor((recordStripTop + recordStripBottom) / 2); 4160 - // Background strip 4161 - ink(dark ? 20 : 235, dark ? 15 : 225, dark ? 30 : 210, 160); 4162 - box(rsX, recordStripTop, rsW, recordStripH, true); 4163 - // Center zero-line 4164 - ink(dark ? 80 : 140, dark ? 80 : 140, dark ? 90 : 150, 120); 4165 - line(rsX, midY, rsX + rsW, midY); 4166 - // Per-pixel-column peak of that time-slice. 4167 - const samplesPerCol = rsData.length / rsW; 4168 - const amp = Math.floor(recordStripH * 0.45); 4169 - for (let x = 0; x < rsW; x++) { 4170 - const i0 = Math.floor(x * samplesPerCol); 4171 - const i1 = Math.min(rsData.length, Math.floor((x + 1) * samplesPerCol)); 4172 - let peak = 0; 4173 - for (let i = i0; i < i1; i++) { 4174 - const a = Math.abs(rsData[i]); 4175 - if (a > peak) peak = a; 4176 - } 4177 - // Clip extreme outliers so a transient hot sample doesn't dominate the 4178 - // column-height math and flatten everything else visually. 4179 - if (peak > 1.0) peak = 1.0; 4180 - const h = Math.max(1, Math.round(peak * amp)); 4181 - // Color fades from warm (loud) through amber (mid) to cold (quiet). 4182 - const r = Math.min(255, Math.round(120 + peak * 140)); 4183 - const g = Math.round(120 + peak * 80); 4184 - const b = Math.round(90 + (1 - peak) * 120); 4185 - ink(r, g, b, 220); 4186 - line(rsX + x, midY - h, rsX + x, midY + h); 4187 - } 4188 - // Right-edge "record needle" — where new samples are being written. 4189 - ink(240, 80, 80, 220); 4190 - line(rsX + rsW - 1, recordStripTop, rsX + rsW - 1, recordStripBottom); 4162 + const flags = spaceHeld ? 1 : 0; // bit0 = reverse scroll direction 4163 + sound.speaker.drawStrip(rsX, recordStripTop, rsW, recordStripH, 4164 + recordStripSeconds, 0.5, flags); 4191 4165 } 4192 4166 4193 4167 // Waveform visualizer bars only in lanes above pad grids (not full-screen).
+25 -10
fedac/native/src/graph.c
··· 488 488 g->fb = target ? target : g->screen; 489 489 } 490 490 491 + // Module-level cache so repeat calls with the same text don't re-run 492 + // qrcodegen_encodeText (Reed-Solomon + masking — multi-ms on slow CPUs). 493 + // Notepat calls qr("https://notepat.com", ...) every paint frame; caching 494 + // drops the cost to ~1 memcmp + the draw loop. 495 + static char qr_cache_text[256] = {0}; 496 + static uint8_t qr_cache_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)]; 497 + static int qr_cache_size = 0; 498 + 491 499 void graph_qr(ACGraph *g, const char *text, int x, int y, int scale) { 492 500 if (!g || !text || !text[0]) return; 493 501 if (scale < 1) scale = 1; 494 502 495 - uint8_t qr_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)]; 496 - uint8_t tmp_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)]; 497 - 498 - if (!qrcodegen_encodeText(text, tmp_buf, qr_buf, 499 - qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, 10, 500 - qrcodegen_Mask_AUTO, true)) { 501 - return; // encode failed (text too long for version 10) 503 + // Cache hit: skip the encode entirely. 504 + if (qr_cache_size == 0 || 505 + strncmp(qr_cache_text, text, sizeof(qr_cache_text)) != 0) { 506 + uint8_t tmp_buf[qrcodegen_BUFFER_LEN_FOR_VERSION(10)]; 507 + if (!qrcodegen_encodeText(text, tmp_buf, qr_cache_buf, 508 + qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, 10, 509 + qrcodegen_Mask_AUTO, true)) { 510 + qr_cache_size = 0; 511 + qr_cache_text[0] = '\0'; 512 + return; 513 + } 514 + qr_cache_size = qrcodegen_getSize(qr_cache_buf); 515 + strncpy(qr_cache_text, text, sizeof(qr_cache_text) - 1); 516 + qr_cache_text[sizeof(qr_cache_text) - 1] = '\0'; 502 517 } 503 518 504 - int size = qrcodegen_getSize(qr_buf); 505 - int margin = 2; // quiet zone 519 + const int size = qr_cache_size; 520 + const int margin = 2; // quiet zone 506 521 507 522 // Draw white background with margin 508 523 int total = (size + margin * 2) * scale; ··· 514 529 graph_ink(g, (ACColor){0, 0, 0, 255}); 515 530 for (int qy = 0; qy < size; qy++) { 516 531 for (int qx = 0; qx < size; qx++) { 517 - if (qrcodegen_getModule(qr_buf, qx, qy)) { 532 + if (qrcodegen_getModule(qr_cache_buf, qx, qy)) { 518 533 graph_box(g, x + (qx + margin) * scale, y + (qy + margin) * scale, 519 534 scale, scale, 1); 520 535 }
+119
fedac/native/src/js-bindings.c
··· 1568 1568 return JS_TRUE; 1569 1569 } 1570 1570 1571 + // sound.speaker.drawStrip(x, y, w, h, seconds, needleFrac, flags) 1572 + // Renders a full scrolling waveform strip in a SINGLE C call — bypasses 1573 + // the per-column JS loop (previously ~600 line() calls + inner sample 1574 + // scan per frame, which pinned notepat at 20 FPS on slow Intel m3 CPUs). 1575 + // 1576 + // Lays out like a DJ-turntable strip: 1577 + // - background + zero-line drawn once 1578 + // - per-pixel-column peak computed on the speaker output ring (taken 1579 + // under the audio lock) — no intermediate JS buffer allocation 1580 + // - warm→cold color ramp based on peak magnitude 1581 + // - optional playhead needle at `needleFrac` (0.0..1.0) of `w` 1582 + // 1583 + // flags bit 0: reverse — oldest sample on the RIGHT, newest on the LEFT. 1584 + // When a piece holds a backwards-replay key (e.g. notepat spacebar), 1585 + // flip this to make the waveform appear to scroll right-to-left. 1586 + static JSValue js_speaker_draw_strip(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1587 + (void)this_val; 1588 + if (!current_rt || argc < 4) return JS_UNDEFINED; 1589 + ACAudio *audio = current_rt->audio; 1590 + ACGraph *g = current_rt->graph; 1591 + if (!audio || !audio->output_history_buf || audio->output_history_size <= 0 || !g) { 1592 + return JS_UNDEFINED; 1593 + } 1594 + 1595 + int x = 0, y = 0, w = 0, h = 0; 1596 + JS_ToInt32(ctx, &x, argv[0]); 1597 + JS_ToInt32(ctx, &y, argv[1]); 1598 + JS_ToInt32(ctx, &w, argv[2]); 1599 + JS_ToInt32(ctx, &h, argv[3]); 1600 + double seconds = 4.0; 1601 + if (argc >= 5 && JS_IsNumber(argv[4])) JS_ToFloat64(ctx, &seconds, argv[4]); 1602 + double needle_frac = 0.5; 1603 + if (argc >= 6 && JS_IsNumber(argv[5])) JS_ToFloat64(ctx, &needle_frac, argv[5]); 1604 + int flags = 0; 1605 + if (argc >= 7 && JS_IsNumber(argv[6])) JS_ToInt32(ctx, &flags, argv[6]); 1606 + int reverse = (flags & 1) ? 1 : 0; 1607 + 1608 + if (w <= 2 || h <= 2 || seconds <= 0.0) return JS_UNDEFINED; 1609 + if (needle_frac < 0.0) needle_frac = 0.0; 1610 + if (needle_frac > 1.0) needle_frac = 1.0; 1611 + 1612 + pthread_mutex_lock(&audio->lock); 1613 + unsigned int rate = audio->output_history_rate ? audio->output_history_rate 1614 + : AUDIO_OUTPUT_HISTORY_RATE; 1615 + int hist_size = audio->output_history_size; 1616 + int want_len = (int)(seconds * (double)rate + 0.5); 1617 + if (want_len < w) want_len = w; // at least 1 sample/col 1618 + if (want_len > hist_size) want_len = hist_size; 1619 + uint64_t write_pos = audio->output_history_write_pos; 1620 + int available = write_pos < (uint64_t)hist_size ? (int)write_pos : hist_size; 1621 + if (available < want_len) want_len = available; 1622 + 1623 + // Copy the slice into a local buffer while holding the lock, then 1624 + // unlock before drawing. Keeps audio-thread contention to the minimum 1625 + // (~4s @ 48kHz = 192K floats = 768KB memcpy, but on modern CPUs that's 1626 + // ~200μs and doesn't stall the audio callback). 1627 + float *copy = NULL; 1628 + if (want_len > 0) { 1629 + copy = (float *)malloc((size_t)want_len * sizeof(float)); 1630 + if (copy) { 1631 + uint64_t start = write_pos - (uint64_t)want_len; 1632 + for (int i = 0; i < want_len; i++) { 1633 + copy[i] = audio->output_history_buf[(start + (uint64_t)i) % (uint64_t)hist_size]; 1634 + } 1635 + } 1636 + } 1637 + pthread_mutex_unlock(&audio->lock); 1638 + 1639 + int midY = y + h / 2; 1640 + ACColor saved = g->ink; 1641 + 1642 + // Background + zero-line (always drawn even if no audio yet) 1643 + graph_ink(g, (ACColor){20, 15, 30, 160}); 1644 + graph_box(g, x, y, w, h, 1); 1645 + graph_ink(g, (ACColor){80, 80, 90, 120}); 1646 + graph_line(g, x, midY, x + w - 1, midY); 1647 + 1648 + if (copy && want_len > 0) { 1649 + int amp = (int)((double)h * 0.45); 1650 + if (amp < 1) amp = 1; 1651 + double samples_per_col = (double)want_len / (double)w; 1652 + 1653 + for (int col = 0; col < w; col++) { 1654 + int col_idx = reverse ? (w - 1 - col) : col; 1655 + int i0 = (int)((double)col * samples_per_col); 1656 + int i1 = (int)((double)(col + 1) * samples_per_col); 1657 + if (i1 > want_len) i1 = want_len; 1658 + if (i0 < 0) i0 = 0; 1659 + float peak = 0.0f; 1660 + for (int i = i0; i < i1; i++) { 1661 + float a = copy[i]; 1662 + if (a < 0) a = -a; 1663 + if (a > peak) peak = a; 1664 + } 1665 + if (peak > 1.0f) peak = 1.0f; 1666 + int bar_h = (int)(peak * (float)amp + 0.5f); 1667 + if (bar_h < 1) bar_h = 1; 1668 + int r = 120 + (int)(peak * 140.0f + 0.5f); if (r > 255) r = 255; 1669 + int gc = 120 + (int)(peak * 80.0f + 0.5f); if (gc > 255) gc = 255; 1670 + int b = 90 + (int)((1.0f - peak) * 120.0f + 0.5f); if (b > 255) b = 255; 1671 + graph_ink(g, (ACColor){(uint8_t)r, (uint8_t)gc, (uint8_t)b, 220}); 1672 + graph_line(g, x + col_idx, midY - bar_h, x + col_idx, midY + bar_h); 1673 + } 1674 + } 1675 + if (copy) free(copy); 1676 + 1677 + // Playhead needle 1678 + int needle_x = x + (int)((double)w * needle_frac + 0.5); 1679 + if (needle_x < x) needle_x = x; 1680 + if (needle_x >= x + w) needle_x = x + w - 1; 1681 + graph_ink(g, (ACColor){240, 80, 80, 220}); 1682 + graph_line(g, needle_x, y, needle_x, y + h - 1); 1683 + 1684 + g->ink = saved; 1685 + return JS_UNDEFINED; 1686 + } 1687 + 1571 1688 // sound.speaker.getRecentBuffer(seconds) -> { data: Float32Array, rate: number } 1572 1689 static JSValue js_speaker_get_recent_buffer(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1573 1690 (void)this_val; ··· 2626 2743 JS_SetPropertyStr(ctx, speaker, "poll", JS_NewCFunction(ctx, js_noop, "poll", 0)); 2627 2744 JS_SetPropertyStr(ctx, speaker, "getRecentBuffer", 2628 2745 JS_NewCFunction(ctx, js_speaker_get_recent_buffer, "getRecentBuffer", 1)); 2746 + JS_SetPropertyStr(ctx, speaker, "drawStrip", 2747 + JS_NewCFunction(ctx, js_speaker_draw_strip, "drawStrip", 7)); 2629 2748 JS_SetPropertyStr(ctx, speaker, "sampleRate", 2630 2749 JS_NewInt32(ctx, rt->audio ? (int)rt->audio->actual_rate : AUDIO_SAMPLE_RATE)); 2631 2750