Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

notepat: smooth continuous drift on spacebar — no layout jump

Rewrote sound.speaker.drawStrip (C) and its JS call site to replace the
half-strip-flipping reverse flag with a continuous `viewOffsetSec`
cursor model. On spacebar press/release the waveform now drifts
smoothly between directions instead of snapping across the needle.

New semantics (matches user's mental model: "needle is what plays the
audio", drift backwards on space hold, drift forwards on release):

drawStrip(x, y, w, h, seconds, needleFrac, viewOffsetSec)

viewOffsetSec = 0 → cursor sits at live capture edge
past extends LEFT of needle
right half stays empty
wave drifts LEFT each frame (natural)
viewOffsetSec > 0, growing → cursor retreats into past
left half: samples before cursor
right half: samples captured AFTER cursor,
proportional to offset
wave drifts RIGHT
viewOffsetSec shrinking → cursor catches back up to "now"
wave drifts LEFT faster than normal
right half empties as we converge

notepat.mjs sim() advances the offset at 1× real-time while spaceHeld
(natural-speed backwards scrub), and retreats it at 2× on release
(snappy catch-up). Cap at 2.0 s so the right half fully fills at the
limit. Needle tints orange-ish when offset>0 so the replay mode is
still visually distinct without any layout jump.

Also: revert QR to scale=1 (25×25 px) — user wanted more space BELOW
the QR in the top bar, not a bigger QR itself. Keep topBarH=54 for
the breathing room, reset qrW back to 28 px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+180 -115
+43 -22
fedac/native/pieces/notepat.mjs
··· 137 137 let driveMix = 0; 138 138 let driveDragging = false; 139 139 140 + // Waveform-strip view cursor — how many seconds in the past the playhead 141 + // sits relative to the live audio edge. 0 = live (wave drifts LEFT as 142 + // real time advances). Grows when spacebar is held (wave drifts RIGHT, 143 + // backwards-replay scrub). Shrinks back to 0 on release so the display 144 + // catches up to live audio over ~0.5 s. 145 + let waveViewOffsetSec = 0; 146 + // Max retreat — also caps how much of the right half can fill in with 147 + // post-cursor audio. Matches recordStripSeconds / 2 so the right half 148 + // fully paints when the cursor has retreated half the visible window. 149 + const WAVE_VIEW_MAX_OFFSET_SEC = 2.0; 150 + 140 151 // Pitch shift — assignable to either trackpad axis 141 152 let pitchShift = 0; // -1 to +1, 0 = no shift 142 153 let lastAppliedPitch = 0; // last pitch actually sent to synths (throttle) ··· 3435 3446 if (reserveSysBrt >= 0) statusRightReserve += 4 + 16 + 2 + 3 * CH; 3436 3447 const statusRightLimit = Math.max(80, w - statusRightReserve - 8); 3437 3448 3438 - // Left: QR code → notepat.com, scannable at arm's length from a phone 3439 - // camera. Scale-2, version-1 with 2-module quiet zone = 50×50 px. C 3440 - // side caches the Reed-Solomon encoding so only the module-grid blit 3441 - // is per-frame cost. 3449 + // Left: QR code → notepat.com. Scale=1, version-1 with 2-module quiet 3450 + // zone = 25×25 px. Taller top bar (54 px) leaves breathing room below 3451 + // the QR for the label + status text without cramping. C side caches 3452 + // the Reed-Solomon encoding so the inner module-grid blit is the only 3453 + // per-frame cost. 3442 3454 if (globalThis.qr) { 3443 - globalThis.qr("https://notepat.com", 2, 2, 2); 3455 + globalThis.qr("https://notepat.com", 2, 2, 1); 3444 3456 } 3445 - const qrW = 54; // 50px QR + 2px left inset + 2px right padding before label 3457 + const qrW = 28; // 25px QR + 2px left inset + 1px right padding before label 3446 3458 const labelX = qrW + 4; 3447 3459 const labelW = 48; // "notepat.com" label width in matrix font at size=1 3448 3460 const npHovered = hoverX >= 0 && hoverX <= labelX + labelW && hoverY < topBarH; ··· 4232 4244 const leftX = margin; 4233 4245 const rightX = w - gridW - margin; 4234 4246 4235 - // Scrolling record-needle strip: the last ~4 seconds of mixed speaker 4236 - // output, always rolling. Classic DJ-turntable display with the playhead 4237 - // pinned at CENTER (left half = past that just played, right half = 4238 - // about-to-be-replaced samples waiting to scroll out). 4247 + // Scrolling record-needle strip with continuous-drift playback cursor. 4239 4248 // 4240 - // Rendered in a single C call (sound.speaker.drawStrip) which does: 4241 - // - background + zero-line 4242 - // - per-pixel-column peak scan on the speaker ring (under audio lock) 4243 - // - warm→cold color ramp 4244 - // - needle at needleFrac 4245 - // Previously this was a 600-iteration JS loop + getRecentBuffer copy 4246 - // every 4 frames; the C path runs every frame at negligible cost. 4249 + // waveViewOffsetSec drives the cursor's position relative to live audio: 4250 + // = 0 → cursor at live edge, past on LEFT, right empty; 4251 + // wave drifts LEFT as new audio arrives 4252 + // > 0, growing → cursor retreats into the past; right half fills in 4253 + // with samples captured AFTER the cursor; wave drifts 4254 + // RIGHT (backwards-replay visual) 4255 + // > 0, shrinking→ cursor catches back up to "now"; wave drifts LEFT 4256 + // faster than normal until offset hits 0 4247 4257 // 4248 - // When spacebar is held the `reverse` flag flips so the waveform appears 4249 - // to scroll the OPPOSITE direction — treating the held-space as a 4250 - // backwards-replay cursor rather than a live capture. 4258 + // waveViewOffsetSec is advanced/retreated in sim() below based on 4259 + // spaceHeld. The C drawStrip reads the offset and renders accordingly 4260 + // in a single call (no JS peak loop). 4251 4261 const recordStripH = 22; 4252 4262 const recordStripSeconds = 4; 4253 4263 const recordStripTop = Math.max(topBarH + 1, gridTop - recordStripH - 2); 4254 4264 if (sound?.speaker?.drawStrip) { 4255 4265 const rsX = margin; 4256 4266 const rsW = w - margin * 2; 4257 - const flags = spaceHeld ? 1 : 0; // bit0 = reverse scroll direction 4258 4267 sound.speaker.drawStrip(rsX, recordStripTop, rsW, recordStripH, 4259 - recordStripSeconds, 0.5, flags); 4268 + recordStripSeconds, 0.5, waveViewOffsetSec); 4260 4269 } 4261 4270 4262 4271 // Waveform visualizer bars only in lanes above pad grids (not full-screen). ··· 5647 5656 // Auto-stop recording at max duration 5648 5657 if (recording && (Date.now() - recStartTime) / 1000 >= MAX_REC_SECS) { 5649 5658 stopSampleRecording(sound, "max-duration"); 5659 + } 5660 + 5661 + // Advance / retreat the waveform-strip view cursor based on spacebar. 5662 + // 1x audio-rate retreat on press → the wave drifts RIGHT at a natural 5663 + // speed. 2x catch-up on release → wave snaps forward noticeably faster 5664 + // than normal drift so the eye can tell the cursor is "coming back". 5665 + const dtSec = 1 / 60; 5666 + if (spaceHeld) { 5667 + waveViewOffsetSec = Math.min(WAVE_VIEW_MAX_OFFSET_SEC, 5668 + waveViewOffsetSec + dtSec); 5669 + } else if (waveViewOffsetSec > 0) { 5670 + waveViewOffsetSec = Math.max(0, waveViewOffsetSec - dtSec * 2); 5650 5671 } 5651 5672 // Update dark/light mode via global theme (every ~5 seconds) 5652 5673 if (frame % 300 === 0) {
+137 -93
fedac/native/src/js-bindings.c
··· 1588 1588 return JS_TRUE; 1589 1589 } 1590 1590 1591 - // sound.speaker.drawStrip(x, y, w, h, seconds, needleFrac, flags) 1592 - // Renders a full scrolling waveform strip in a SINGLE C call — bypasses 1593 - // the per-column JS loop (previously ~600 line() calls + inner sample 1594 - // scan per frame, which pinned notepat at 20 FPS on slow Intel m3 CPUs). 1591 + // sound.speaker.drawStrip(x, y, w, h, seconds, needleFrac, viewOffsetSec) 1592 + // Renders a scrolling waveform strip in one C call. The needle stays at 1593 + // `needleFrac` (0.0..1.0) of the strip width and represents the current 1594 + // PLAYBACK CURSOR — what's being heard right now. The wave never jumps: 1595 + // it continuously drifts based on how the cursor moves through the buffer 1596 + // across frames. 1597 + // 1598 + // viewOffsetSec controls the cursor's position relative to "now": 1595 1599 // 1596 - // Lays out like a DJ-turntable strip: 1597 - // - background + zero-line drawn once 1598 - // - per-pixel-column peak computed on the speaker output ring (taken 1599 - // under the audio lock) — no intermediate JS buffer allocation 1600 - // - warm→cold color ramp based on peak magnitude 1601 - // - optional playhead needle at `needleFrac` (0.0..1.0) of `w` 1600 + // 0.0 → cursor sits at the latest captured sample. Past audio 1601 + // extends to the LEFT of the needle. Right of needle is empty 1602 + // (no future). As real time advances, freshly-captured samples 1603 + // appear at the needle and the wave drifts LEFT — the natural 1604 + // scroll for live capture. 1602 1605 // 1603 - // flags bit 0: reverse — oldest sample on the RIGHT, newest on the LEFT. 1604 - // When a piece holds a backwards-replay key (e.g. notepat spacebar), 1605 - // flip this to make the waveform appear to scroll right-to-left. 1606 + // > 0 → cursor is OFFSET-seconds in the past. Left of needle still 1607 + // shows the LEFT-PAST (older than cursor). Right of needle 1608 + // shows the RIGHT-PAST (samples captured AFTER the cursor 1609 + // position, up to the live edge). As JS grows the offset (e.g. 1610 + // while spacebar is held), the cursor retreats further into 1611 + // the past and the wave appears to drift RIGHT — exactly 1612 + // matching a backwards-replay scrub. 1613 + // 1614 + // shrinking offset on release → cursor catches back up to "now" and 1615 + // the wave drifts LEFT (forward) until the right side empties 1616 + // and we're back in live capture mode. 1617 + // 1618 + // Per-pixel peak math is C-native (no JS loop overhead); the audio ring 1619 + // slice is copied under audio->lock then drawn unlocked. 1606 1620 static JSValue js_speaker_draw_strip(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1607 1621 (void)this_val; 1608 1622 if (!current_rt || argc < 4) return JS_UNDEFINED; ··· 1621 1635 if (argc >= 5 && JS_IsNumber(argv[4])) JS_ToFloat64(ctx, &seconds, argv[4]); 1622 1636 double needle_frac = 0.5; 1623 1637 if (argc >= 6 && JS_IsNumber(argv[5])) JS_ToFloat64(ctx, &needle_frac, argv[5]); 1624 - int flags = 0; 1625 - if (argc >= 7 && JS_IsNumber(argv[6])) JS_ToInt32(ctx, &flags, argv[6]); 1626 - int reverse = (flags & 1) ? 1 : 0; 1638 + double view_offset_sec = 0.0; 1639 + if (argc >= 7 && JS_IsNumber(argv[6])) JS_ToFloat64(ctx, &view_offset_sec, argv[6]); 1640 + if (view_offset_sec < 0.0) view_offset_sec = 0.0; 1627 1641 1628 1642 if (w <= 2 || h <= 2 || seconds <= 0.0) return JS_UNDEFINED; 1629 1643 if (needle_frac < 0.0) needle_frac = 0.0; 1630 1644 if (needle_frac > 1.0) needle_frac = 1.0; 1631 1645 1646 + int needle_off = (int)((double)w * needle_frac + 0.5); 1647 + if (needle_off < 0) needle_off = 0; 1648 + if (needle_off >= w) needle_off = w - 1; 1649 + int needle_x = x + needle_off; 1650 + 1651 + // Compute the time window the strip needs to span: 1652 + // [cursor - left_seconds, cursor + right_seconds] 1653 + // where cursor = now - view_offset_sec, and the LEFT/RIGHT widths in 1654 + // seconds are proportional to the LEFT/RIGHT pixel widths so each 1655 + // pixel covers the same temporal slice end-to-end. 1656 + int left_w = needle_off; 1657 + int right_w = w - needle_off; 1658 + double total_w = (double)w; 1659 + double left_seconds = seconds * ((double)left_w / total_w); 1660 + double right_seconds_max = seconds * ((double)right_w / total_w); 1661 + // Right side only fills to view_offset_sec — no future audio exists. 1662 + double right_seconds = view_offset_sec < right_seconds_max ? view_offset_sec : right_seconds_max; 1663 + 1632 1664 pthread_mutex_lock(&audio->lock); 1633 1665 unsigned int rate = audio->output_history_rate ? audio->output_history_rate 1634 1666 : AUDIO_OUTPUT_HISTORY_RATE; 1635 1667 int hist_size = audio->output_history_size; 1636 - int want_len = (int)(seconds * (double)rate + 0.5); 1637 - if (want_len < w) want_len = w; // at least 1 sample/col 1638 - if (want_len > hist_size) want_len = hist_size; 1639 1668 uint64_t write_pos = audio->output_history_write_pos; 1640 1669 int available = write_pos < (uint64_t)hist_size ? (int)write_pos : hist_size; 1641 - if (available < want_len) want_len = available; 1670 + 1671 + // cursor_pos: ring index of "where the playhead sits" in samples. 1672 + // = write_pos - offset_samples 1673 + uint64_t offset_samples = (uint64_t)(view_offset_sec * (double)rate + 0.5); 1674 + if (offset_samples > write_pos) offset_samples = write_pos; 1675 + uint64_t cursor_pos = write_pos - offset_samples; 1676 + 1677 + // Snapshot the LEFT-past slice [cursor - left_seconds, cursor] 1678 + int left_samples_want = (int)(left_seconds * (double)rate + 0.5); 1679 + if (left_samples_want < 1) left_samples_want = 1; 1680 + if ((uint64_t)left_samples_want > cursor_pos) left_samples_want = (int)cursor_pos; 1681 + if (left_samples_want > available) left_samples_want = available; 1682 + 1683 + // Snapshot the RIGHT-past slice [cursor, cursor + right_seconds] 1684 + int right_samples_want = (int)(right_seconds * (double)rate + 0.5); 1685 + if (right_samples_want < 0) right_samples_want = 0; 1686 + if ((uint64_t)right_samples_want > write_pos - cursor_pos) { 1687 + right_samples_want = (int)(write_pos - cursor_pos); 1688 + } 1642 1689 1643 - // Copy the slice into a local buffer while holding the lock, then 1644 - // unlock before drawing. Keeps audio-thread contention to the minimum 1645 - // (~4s @ 48kHz = 192K floats = 768KB memcpy, but on modern CPUs that's 1646 - // ~200μs and doesn't stall the audio callback). 1647 - float *copy = NULL; 1648 - if (want_len > 0) { 1649 - copy = (float *)malloc((size_t)want_len * sizeof(float)); 1650 - if (copy) { 1651 - uint64_t start = write_pos - (uint64_t)want_len; 1652 - for (int i = 0; i < want_len; i++) { 1653 - copy[i] = audio->output_history_buf[(start + (uint64_t)i) % (uint64_t)hist_size]; 1690 + float *left_copy = NULL; 1691 + float *right_copy = NULL; 1692 + if (left_samples_want > 0) { 1693 + left_copy = (float *)malloc((size_t)left_samples_want * sizeof(float)); 1694 + if (left_copy) { 1695 + uint64_t start = cursor_pos - (uint64_t)left_samples_want; 1696 + for (int i = 0; i < left_samples_want; i++) { 1697 + left_copy[i] = audio->output_history_buf[(start + (uint64_t)i) % (uint64_t)hist_size]; 1698 + } 1699 + } 1700 + } 1701 + if (right_samples_want > 0) { 1702 + right_copy = (float *)malloc((size_t)right_samples_want * sizeof(float)); 1703 + if (right_copy) { 1704 + uint64_t start = cursor_pos; 1705 + for (int i = 0; i < right_samples_want; i++) { 1706 + right_copy[i] = audio->output_history_buf[(start + (uint64_t)i) % (uint64_t)hist_size]; 1654 1707 } 1655 1708 } 1656 1709 } 1657 1710 pthread_mutex_unlock(&audio->lock); 1658 1711 1659 1712 int midY = y + h / 2; 1713 + int amp = (int)((double)h * 0.45); 1714 + if (amp < 1) amp = 1; 1660 1715 ACColor saved = g->ink; 1661 1716 1662 - // Background + zero-line (always drawn even if no audio yet) 1717 + // Background + zero-line 1663 1718 graph_ink(g, (ACColor){20, 15, 30, 160}); 1664 1719 graph_box(g, x, y, w, h, 1); 1665 1720 graph_ink(g, (ACColor){80, 80, 90, 120}); 1666 1721 graph_line(g, x, midY, x + w - 1, midY); 1667 1722 1668 - // Needle position within strip (offset 0..w from x). 1669 - int needle_off = (int)((double)w * needle_frac + 0.5); 1670 - if (needle_off < 0) needle_off = 0; 1671 - if (needle_off >= w) needle_off = w - 1; 1672 - int needle_x = x + needle_off; 1723 + // Inner draw helper: render `len` samples across `pixel_w` pixels 1724 + // starting at draw_x, with column 0 = oldest sample. 1725 + #define DRAW_SLICE(buf, len, pixel_w, draw_x_off) do { \ 1726 + if ((buf) && (len) > 0 && (pixel_w) > 0) { \ 1727 + double spc = (double)(len) / (double)(pixel_w); \ 1728 + for (int col = 0; col < (pixel_w); col++) { \ 1729 + int i0 = (int)((double)col * spc); \ 1730 + int i1 = (int)((double)(col + 1) * spc); \ 1731 + if (i1 > (len)) i1 = (len); \ 1732 + if (i0 < 0) i0 = 0; \ 1733 + if (i1 <= i0) continue; \ 1734 + float peak = 0.0f; \ 1735 + for (int i = i0; i < i1; i++) { \ 1736 + float a = (buf)[i]; \ 1737 + if (a < 0) a = -a; \ 1738 + if (a > peak) peak = a; \ 1739 + } \ 1740 + if (peak > 1.0f) peak = 1.0f; \ 1741 + int bar_h = (int)(peak * (float)amp + 0.5f); \ 1742 + if (bar_h < 1) bar_h = 1; \ 1743 + int r = 120 + (int)(peak * 140.0f + 0.5f); if (r > 255) r = 255; \ 1744 + int gc = 120 + (int)(peak * 80.0f + 0.5f); if (gc > 255) gc = 255; \ 1745 + int b_ = 90 + (int)((1.0f - peak) * 120.0f + 0.5f); if (b_ > 255) b_ = 255; \ 1746 + graph_ink(g, (ACColor){(uint8_t)r, (uint8_t)gc, (uint8_t)b_, 220}); \ 1747 + int dx = x + (draw_x_off) + col; \ 1748 + graph_line(g, dx, midY - bar_h, dx, midY + bar_h); \ 1749 + } \ 1750 + } \ 1751 + } while (0) 1673 1752 1674 - // Layout principle: the NEW sample (most recent in the ring) lands 1675 - // immediately adjacent to the needle, and older samples extend AWAY 1676 - // from the needle along the active half. The other half stays empty 1677 - // (background). This matches a record-player intuition: the needle 1678 - // is the playhead and "now" sits right under it. 1679 - // 1680 - // normal mode (reverse=0): active half = LEFT (newest just-left of needle) 1681 - // reverse mode (reverse=1): active half = RIGHT (newest just-right of needle, 1682 - // past tail extends further right as the held 1683 - // spacebar replays backward in time) 1684 - // 1685 - // If needle is at the strip edge, we degrade gracefully: active half 1686 - // covers the whole width. 1687 - int active_w; 1688 - int active_start_off; // pixel offset within strip where the active half begins 1689 - if (!reverse) { 1690 - active_w = needle_off > 0 ? needle_off : w; 1691 - active_start_off = needle_off > 0 ? 0 : 0; 1692 - } else { 1693 - active_w = (w - needle_off) > 0 ? (w - needle_off) : w; 1694 - active_start_off = (w - needle_off) > 0 ? needle_off : 0; 1695 - } 1753 + // LEFT half: samples cover full left_w pixels (oldest at column 0). 1754 + DRAW_SLICE(left_copy, left_samples_want, left_w, 0); 1755 + 1756 + // RIGHT half: samples cover only the portion proportional to view_offset. 1757 + // If offset is small, the right half is mostly empty (background shows 1758 + // through). If offset reaches max, right half is fully drawn. 1759 + int right_pixels_filled = right_samples_want > 0 1760 + ? (int)((double)right_samples_want / (double)rate / right_seconds_max * (double)right_w + 0.5) 1761 + : 0; 1762 + if (right_pixels_filled > right_w) right_pixels_filled = right_w; 1763 + DRAW_SLICE(right_copy, right_samples_want, right_pixels_filled, needle_off); 1696 1764 1697 - if (copy && want_len > 0 && active_w > 0) { 1698 - int amp = (int)((double)h * 0.45); 1699 - if (amp < 1) amp = 1; 1700 - double samples_per_col = (double)want_len / (double)active_w; 1765 + #undef DRAW_SLICE 1701 1766 1702 - for (int col = 0; col < active_w; col++) { 1703 - // Distance from the needle (0 = adjacent to needle = newest sample). 1704 - int dist_from_needle = !reverse ? (active_w - 1 - col) : col; 1705 - // Map dist→sample range: dist 0 ⇢ samples near want_len-1 (newest), 1706 - // dist active_w-1 ⇢ samples near 0 (oldest). 1707 - int i1 = want_len - (int)((double)dist_from_needle * samples_per_col); 1708 - int i0 = want_len - (int)((double)(dist_from_needle + 1) * samples_per_col); 1709 - if (i0 < 0) i0 = 0; 1710 - if (i1 > want_len) i1 = want_len; 1711 - if (i1 <= i0) continue; 1767 + if (left_copy) free(left_copy); 1768 + if (right_copy) free(right_copy); 1712 1769 1713 - float peak = 0.0f; 1714 - for (int i = i0; i < i1; i++) { 1715 - float a = copy[i]; 1716 - if (a < 0) a = -a; 1717 - if (a > peak) peak = a; 1718 - } 1719 - if (peak > 1.0f) peak = 1.0f; 1720 - int bar_h = (int)(peak * (float)amp + 0.5f); 1721 - if (bar_h < 1) bar_h = 1; 1722 - int r = 120 + (int)(peak * 140.0f + 0.5f); if (r > 255) r = 255; 1723 - int gc = 120 + (int)(peak * 80.0f + 0.5f); if (gc > 255) gc = 255; 1724 - int b = 90 + (int)((1.0f - peak) * 120.0f + 0.5f); if (b > 255) b = 255; 1725 - graph_ink(g, (ACColor){(uint8_t)r, (uint8_t)gc, (uint8_t)b, 220}); 1726 - int draw_x = x + active_start_off + col; 1727 - graph_line(g, draw_x, midY - bar_h, draw_x, midY + bar_h); 1728 - } 1770 + // Playhead needle — draw last so it sits on top of the bars. Color 1771 + // tints toward orange when the cursor is offset (replay mode) so it's 1772 + // visually distinct from the live red needle. 1773 + if (view_offset_sec > 0.001) { 1774 + graph_ink(g, (ACColor){255, 160, 60, 230}); 1775 + } else { 1776 + graph_ink(g, (ACColor){240, 80, 80, 220}); 1729 1777 } 1730 - if (copy) free(copy); 1731 - 1732 - // Playhead needle — draw last so it sits on top of the bars. 1733 - graph_ink(g, (ACColor){240, 80, 80, 220}); 1734 1778 graph_line(g, needle_x, y, needle_x, y + h - 1); 1735 1779 1736 1780 g->ink = saved;