Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

fix(dj): detect stale /media mount on unplug + rebuild notepat help panel

Two related notepat/native fixes:

1. USB hotplug regression — plug in, unplug, re-plug would stop
detecting the music USB. Root cause: mountMusic() only checked
`st_dev` to decide "already mounted", but when a USB is physically
yanked /media stays as a stale mount point (st_dev still differs
from root), so mountMusic() kept returning true forever. The JS
hot-plug loop in notepat sim() then thought USB was still
connected and never re-scanned.

Fix: parse /proc/mounts to find the actual device backing /media,
verify the device file still exists (S_ISBLK + stat) AND opendir
succeeds. If either check fails (stale mount from a yanked USB),
umount2(MNT_DETACH) lazily before falling through to the normal
mount loop. This lets re-plugs mount fresh.

2. Meta-held help panel rewrite. Previous panel had several issues:
- Bindings were stale (space listed as "kick drum" but is now the
reverse loop pedal; F1-F4 listed as "Fn+F1" etc but the media-
key aliases were removed so they're direct)
- Fixed 280px width wasted screen on wider displays
- 24 single-column rows bled off the bottom on small screens
- Two flat colors for key + desc gave no visual grouping

New panel:
- Categories: NOTES, DRUMS, WAVE, DECK, HOLD, TEMPO, SAMPLE, FX,
SYSTEM — each in a distinct color (cyan, orange, magenta, green,
yellow, amber, pink, teal, gray) applied to both the category
header and the key-column text
- Auto 1/2/3 columns based on available width (COL_W = 172 px)
- Panel height auto-fits content AND hard-clamps to screen height
so it never bleeds off the bottom; overflow rows get clipped
rather than pushed off-screen
- Key colors brightened in dark mode, darkened in light mode for
legibility on both themes
- Footer hint always positioned inside the panel
- All bindings updated to reflect current code state

+180 -55
+141 -49
fedac/native/pieces/notepat.mjs
··· 3743 3743 } 3744 3744 } 3745 3745 3746 - // Help panel overlay (Meta/Win key) — drawn last so it sits on top of everything 3746 + // Help panel overlay (Meta/Win key) — drawn last so it sits on top of everything. 3747 + // 3748 + // Design goals: 3749 + // - Color-coded by category (notes, drums, wave, deck, hold, tempo, sample, fx, system) 3750 + // - Multi-column layout that adapts to screen width (1, 2, or 3 columns) 3751 + // - Panel height auto-fits content AND hard-clamps to screen height so it 3752 + // never bleeds off the bottom 3753 + // - Uses available width rather than being capped at 280 3754 + // - Bindings reflect current code (space = reverse loop pedal, F1–F4 direct 3755 + // deck control, F9 metronome, F10/F11 hold, F12 recital) 3747 3756 if (helpPanel) { 3748 3757 const dark = isDark(); 3758 + const margin = 10; 3749 3759 const padX = 8, padY = 8; 3750 - const lineH = 11; 3751 - const shortcuts = [ 3752 - ["a–l, ; '", "play notes (with sharps)"], 3753 - ["1–9 / ↑↓", "octave"], 3754 - ["pgup / pgdn", "drum kit L / R octave"], 3755 - ["space", "kick drum"], 3756 - ["tab", "cycle wave type"], 3757 - ["shift", "quick mode"], 3758 - ["Fn+F1", "deck play/pause"], 3759 - ["Fn+F2", "deck next track"], 3760 - ["Fn+F3", "deck prev track"], 3761 - ["Fn+F4", "deck usb rescan"], 3762 - ["[ / `", "deck speed − / reset"], 3763 - [", tap tempo", "sync metronome to deck"], 3764 - ["drag deck", "scratch the platter"], 3765 - ["F9", "metronome"], 3766 - ["F10 (📞)", "clear hold"], 3767 - ["F11 (📞)", "engage / flourish hold"], 3768 - ["F12 (★)", "recital mode (hide UI)"], 3769 - ["meta (⊞)", "toggle this help"], 3770 - ["esc esc esc", "exit to prompt"], 3771 - ["- / =", "metronome BPM"], 3772 - ["home (sample)", "record global sample"], 3773 - ["end (sample)", "arm per-key / per-drum rec"], 3774 - ["\\", "trackpad FX (X echo, Y pitch)"], 3760 + const lineH = 10; 3761 + const titleH = 14; 3762 + const footerH = 11; 3763 + 3764 + // Category [name, color (RGB), entries[[key, desc], ...]] 3765 + // Color = text tint for the key column on that row. Category header is 3766 + // rendered in the same color at full saturation. 3767 + const categories = [ 3768 + ["NOTES", [120, 200, 255], [ 3769 + ["a–l ; '", "play notes"], 3770 + ["1–9 ↑↓", "octave"], 3771 + ["shift", "quick mode"], 3772 + ]], 3773 + ["DRUMS", [255, 140, 90], [ 3774 + ["pgup/pgdn", "drum kit L / R"], 3775 + ["space", "reverse loop pedal"], 3776 + ]], 3777 + ["WAVE", [220, 140, 255], [ 3778 + ["tab", "cycle wave type"], 3779 + ["F12 ★", "recital mode"], 3780 + ]], 3781 + ["DECK", [120, 230, 150], [ 3782 + ["F1", "play / pause"], 3783 + ["F2", "next track"], 3784 + ["F3", "prev track"], 3785 + ["F4", "usb rescan"], 3786 + ["[ / `", "speed − / reset"], 3787 + [",", "tap-sync bpm"], 3788 + ["drag", "scratch platter"], 3789 + ]], 3790 + ["HOLD", [255, 220, 120], [ 3791 + ["F11 📞", "engage / flourish"], 3792 + ["F10 📞", "clear hold"], 3793 + ]], 3794 + ["TEMPO", [255, 180, 100], [ 3795 + ["F9", "metronome"], 3796 + ["- / =", "bpm − / +"], 3797 + ]], 3798 + ["SAMPLE", [255, 150, 210], [ 3799 + ["home", "record global"], 3800 + ["end", "arm per-key/drum"], 3801 + ["delete", "clear sample bank"], 3802 + ]], 3803 + ["FX", [130, 230, 220], [ 3804 + ["\\", "trackpad (X echo, Y pitch)"], 3805 + ]], 3806 + ["SYSTEM", [190, 190, 205], [ 3807 + ["meta ⊞", "toggle this help"], 3808 + ["esc esc esc", "exit to prompt"], 3809 + ]], 3775 3810 ]; 3776 - // Compute panel size 3777 - const titleH = 14; 3778 - const panelW = Math.min(w - 20, 280); 3779 - const panelH = titleH + lineH * shortcuts.length + padY * 2; 3811 + 3812 + // Build flat row list: each category contributes 1 header row + N item rows. 3813 + // Rows are { type: "header"|"item", ... } so the layout pass can handle both. 3814 + const rows = []; 3815 + for (const [cat, color, items] of categories) { 3816 + rows.push({ type: "header", cat, color }); 3817 + for (const [k, desc] of items) { 3818 + rows.push({ type: "item", k, desc, color }); 3819 + } 3820 + } 3821 + 3822 + // Column width: key col ~54px + gap + desc col ~110px = ~172px per column. 3823 + const COL_W = 172; 3824 + const availW = Math.max(160, w - margin * 2); 3825 + // Auto-pick 1/2/3 columns based on available width. 3826 + const numCols = Math.max(1, Math.min(3, Math.floor(availW / COL_W))); 3827 + const rowsPerCol = Math.ceil(rows.length / numCols); 3828 + 3829 + // Desired panel dimensions. 3830 + const desiredW = Math.min(availW, COL_W * numCols + padX * 2); 3831 + const desiredH = titleH + padY * 2 + rowsPerCol * lineH + footerH + 4; 3832 + // Hard clamp to screen so the panel NEVER bleeds off the bottom. 3833 + const maxH = h - margin * 2; 3834 + const panelW = desiredW; 3835 + const panelH = Math.min(desiredH, maxH); 3836 + // If clamped, we may need to shrink rowsPerCol to what actually fits. 3837 + const gridH = panelH - titleH - padY * 2 - footerH - 4; 3838 + const fitRowsPerCol = Math.max(1, Math.floor(gridH / lineH)); 3839 + const actualRowsPerCol = Math.min(rowsPerCol, fitRowsPerCol); 3840 + 3780 3841 const px = Math.floor((w - panelW) / 2); 3781 3842 const py = Math.floor((h - panelH) / 2); 3782 - // Background with shadow 3783 - ink(0, 0, 0, 140); box(px + 3, py + 3, panelW, panelH, true); 3784 - ink(dark ? 22 : 240, dark ? 22 : 240, dark ? 28 : 245); box(px, py, panelW, panelH, true); 3785 - ink(dark ? 80 : 100, dark ? 80 : 100, dark ? 100 : 130); box(px, py, panelW, panelH, "outline"); 3786 - // Title 3787 - ink(dark ? 220 : 40, dark ? 220 : 40, dark ? 230 : 60); 3843 + 3844 + // Background: soft drop shadow + panel fill + subtle outline. 3845 + ink(0, 0, 0, 160); box(px + 3, py + 3, panelW, panelH, true); 3846 + ink(dark ? 14 : 248, dark ? 14 : 248, dark ? 22 : 252); box(px, py, panelW, panelH, true); 3847 + ink(dark ? 90 : 120, dark ? 90 : 120, dark ? 115 : 150); box(px, py, panelW, panelH, "outline"); 3848 + 3849 + // Title bar 3850 + ink(dark ? 235 : 30, dark ? 235 : 30, dark ? 245 : 50); 3788 3851 write("notepat shortcuts", { x: px + padX, y: py + padY, size: 1, font: "font_1" }); 3789 - ink(dark ? 80 : 160, dark ? 80 : 160, dark ? 100 : 180); 3790 - line(px + padX, py + padY + 11, px + panelW - padX, py + padY + 11); 3791 - // Shortcuts list 3792 - let lineY = py + padY + titleH; 3793 - for (const [k, desc] of shortcuts) { 3794 - ink(dark ? 180 : 60, dark ? 200 : 80, dark ? 220 : 100); 3795 - write(k, { x: px + padX, y: lineY, size: 1, font: "font_1" }); 3796 - ink(dark ? 130 : 80, dark ? 130 : 80, dark ? 145 : 100); 3797 - write(desc, { x: px + padX + 90, y: lineY, size: 1, font: "font_1" }); 3798 - lineY += lineH; 3852 + ink(dark ? 80 : 170, dark ? 80 : 170, dark ? 105 : 190); 3853 + line(px + padX, py + padY + 10, px + panelW - padX, py + padY + 10); 3854 + 3855 + // Grid layout: rows flow top→bottom within a column, then wrap to next column. 3856 + const gridY = py + padY + titleH; 3857 + const colGap = Math.floor((panelW - padX * 2) / numCols); 3858 + const descOffset = 54; // x offset within column for description text 3859 + 3860 + for (let i = 0; i < rows.length; i++) { 3861 + const col = Math.floor(i / actualRowsPerCol); 3862 + if (col >= numCols) break; // overflow — rare, only if clamped 3863 + const rowInCol = i % actualRowsPerCol; 3864 + const rx = px + padX + col * colGap; 3865 + const ry = gridY + rowInCol * lineH; 3866 + const r = rows[i]; 3867 + 3868 + if (r.type === "header") { 3869 + // Full-saturation category label 3870 + const [cr, cg, cb] = r.color; 3871 + ink(cr, cg, cb); 3872 + write(r.cat, { x: rx, y: ry, size: 1, font: "font_1" }); 3873 + // Thin underline in the same color, muted 3874 + ink(Math.floor(cr * 0.55), Math.floor(cg * 0.55), Math.floor(cb * 0.55)); 3875 + line(rx, ry + 8, rx + colGap - 8, ry + 8); 3876 + } else { 3877 + // Key: category color, slightly brighter in dark mode for legibility 3878 + const [cr, cg, cb] = r.color; 3879 + if (dark) { 3880 + ink(Math.min(255, cr + 15), Math.min(255, cg + 15), Math.min(255, cb + 15)); 3881 + } else { 3882 + // Darken for light mode contrast 3883 + ink(Math.floor(cr * 0.55), Math.floor(cg * 0.55), Math.floor(cb * 0.55)); 3884 + } 3885 + write(r.k, { x: rx + 6, y: ry, size: 1, font: "font_1" }); 3886 + // Description: muted gray that reads well on both themes 3887 + ink(dark ? 160 : 85, dark ? 165 : 85, dark ? 180 : 100); 3888 + write(r.desc, { x: rx + 6 + descOffset, y: ry, size: 1, font: "font_1" }); 3889 + } 3799 3890 } 3800 - // Hint at bottom 3801 - ink(dark ? 90 : 130, dark ? 90 : 130, dark ? 110 : 150); 3802 - write("press meta (⊞) again to close", { x: px + padX, y: py + panelH - padY - 5, size: 1, font: "font_1" }); 3891 + 3892 + // Footer hint — always visible, always inside the panel 3893 + ink(dark ? 100 : 140, dark ? 100 : 140, dark ? 125 : 160); 3894 + write("meta ⊞ to close", { x: px + padX, y: py + panelH - padY - 2, size: 1, font: "font_1" }); 3803 3895 } 3804 3896 } 3805 3897
+39 -6
fedac/native/src/js-bindings.c
··· 2587 2587 return arr; 2588 2588 } 2589 2589 2590 - // system.mountMusic() — mount secondary USB at /media (read-only), returns true/false 2590 + // system.mountMusic() — mount secondary USB at /media (read-only), returns true/false. 2591 + // Also detects STALE mounts (USB physically yanked) and force-unmounts them so a 2592 + // subsequent plug-in can mount fresh. Without this, /media stays "mounted" forever 2593 + // after an unplug and the JS DJ hot-plug loop never re-detects a new USB. 2591 2594 static JSValue js_mount_music(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 2592 2595 (void)this_val; (void)argc; (void)argv; 2593 2596 2594 2597 mkdir("/media", 0755); 2595 2598 2596 - // Check if already mounted 2597 - struct stat st_media, st_root; 2598 - if (stat("/media/.", &st_media) == 0 && stat("/.", &st_root) == 0 && 2599 - st_media.st_dev != st_root.st_dev) { 2600 - return JS_TRUE; 2599 + // Check /proc/mounts for any existing mount at /media, and capture the device. 2600 + char media_dev[128] = {0}; 2601 + FILE *mf = fopen("/proc/mounts", "r"); 2602 + if (mf) { 2603 + char line[512]; 2604 + while (fgets(line, sizeof(line), mf)) { 2605 + char dev[128], target[128]; 2606 + if (sscanf(line, "%127s %127s", dev, target) == 2 && strcmp(target, "/media") == 0) { 2607 + strncpy(media_dev, dev, sizeof(media_dev) - 1); 2608 + break; 2609 + } 2610 + } 2611 + fclose(mf); 2612 + } 2613 + 2614 + if (media_dev[0]) { 2615 + // There's something mounted at /media. Verify the backing device is still 2616 + // physically present (USB stick didn't get yanked). 2617 + struct stat ds; 2618 + int dev_alive = (stat(media_dev, &ds) == 0 && S_ISBLK(ds.st_mode)); 2619 + // Extra check: opendir succeeds on a live mount but may EIO on a stale one. 2620 + int dir_ok = 0; 2621 + if (dev_alive) { 2622 + DIR *d = opendir("/media"); 2623 + if (d) { dir_ok = 1; closedir(d); } 2624 + } 2625 + if (dev_alive && dir_ok) { 2626 + return JS_TRUE; // legitimate, live mount 2627 + } 2628 + // Stale: device is gone or dir unreadable. Lazy-unmount so we can remount. 2629 + ac_log("[dj] stale /media mount (dev=%s, alive=%d, dir=%d) — detaching\n", 2630 + media_dev, dev_alive, dir_ok); 2631 + if (umount2("/media", MNT_DETACH) != 0) { 2632 + ac_log("[dj] umount /media failed: %s\n", strerror(errno)); 2633 + } 2601 2634 } 2602 2635 2603 2636 // Find ALL mounted devices to skip (boot USB may have multiple partitions)