From f8062779714f9e1f50dd1dd6ae8d1c131d0762bd Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 9 Jun 2026 15:09:54 -0700 Subject: [PATCH] fedac/native: dj.mjs waveform + auto-advance + hold-space scratch; os.mjs LAN source + boot-usb reflash; notepat mute chat TTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dj.mjs: - waveform overview from sound.deck.getPeaks(0): played portion bright, playhead line, falls back to thin bar while peaks decode - robust auto-advance: fires once per track at EOF (advancedForIdx guard), wraps the crate, never mid-scratch/seek - hold space + slide trackpad on X = scratch (pen.x velocity → deck speed via sim); a plain space tap still play/pauses on release os.mjs: - resolveOsSource(): /mnt/os-source.txt repoints OTA at a LAN dev host (self-gating; absent on public images) with a DEV SRC banner - 'b' one-touch reflash of the live boot USB (skips target cycling) notepat.mjs: incoming-chat TTS muted by default on native --- fedac/native/pieces/dj.mjs | 140 +++++++++++++++++++++++++++----- fedac/native/pieces/notepat.mjs | 3 +- fedac/native/pieces/os.mjs | 131 ++++++++++++++++++++++-------- 3 files changed, 219 insertions(+), 55 deletions(-) diff --git a/fedac/native/pieces/dj.mjs b/fedac/native/pieces/dj.mjs index 95cdeb094d..406aa9eef7 100644 --- a/fedac/native/pieces/dj.mjs +++ b/fedac/native/pieces/dj.mjs @@ -22,6 +22,19 @@ let spinSpeed = 0; // record spin rate (0=stopped, 1=playing) let scratchSpeed = 0; // current scratch velocity (-2 to 2) let wasPlaying = false; // was playing before scratch started +// Waveform overview — whole-track peaks fetched once per load. +let peaks = null; // Float array (0..1) or null until decoded + +// Hold-space + trackpad-X scratch. +let spaceHeld = false; // space key currently down +let spaceScratchActive = false; // X movement exceeded the tap threshold +let spaceMoved = 0; // accumulated |dx| since space pressed +let scratchPrevX = 0; // last pen.x sampled in sim +let wasPlayingBeforeScratch = false; + +// Auto-advance guard — advance once per track end, not every frame. +let advancedForIdx = -1; + // Button layout (computed in paint, used in act) let buttons = []; // [{x, y, w, h, id, label}] @@ -66,6 +79,8 @@ function loadTrack(sound) { const f = files[trackIdx]; const ok = sound?.deck?.load(0, f.path); if (ok) { + peaks = null; // re-fetch overview for the new track + advancedForIdx = -1; // re-arm auto-advance for this track msg(f.name.replace(/\.[^.]+$/, "")); say(sound, f.name.replace(/\.[^.]+$/, "")); sound.deck.play(0); @@ -172,19 +187,41 @@ function act({ event: e, sound, system, screen }) { return; } - if (!e.is("keyboard:down")) return; - - if (e.is("keyboard:down:escape")) { system?.jump?.("prompt"); return; } - - // Space: play/pause + // --- Hold-space + trackpad-X scratch --- + // Press space to enter scratch mode; sliding the pointer left/right + // scrubs the deck (sim() drives the speed from pen.x). A space tap with + // no slide falls through to play/pause on release. if (e.is("keyboard:down:space")) { - if (d?.loaded) { - if (d.playing) { dk.pause(0); spinSpeed = 0; msg("paused"); } - else { dk.play(0); spinSpeed = 1; msg("playing"); } + if (!spaceHeld && d?.loaded) { + spaceHeld = true; + spaceScratchActive = false; + spaceMoved = 0; + scratchPrevX = -1; // sim seeds this on its first frame + wasPlayingBeforeScratch = d.playing || false; + } + return; // ignore autorepeat; release handles the tap case + } + if (e.is("keyboard:up:space")) { + if (spaceHeld) { + spaceHeld = false; + if (spaceScratchActive) { + // Was scratching — restore prior transport. + if (wasPlayingBeforeScratch) { dk?.setSpeed(0, 1); dk?.play(0); spinSpeed = 1; } + else { dk?.setSpeed(0, 1); dk?.pause(0); spinSpeed = 0; } + } else if (d?.loaded) { + // Plain tap — play/pause toggle. + if (d.playing) { dk.pause(0); spinSpeed = 0; msg("paused"); } + else { dk.play(0); spinSpeed = 1; msg("playing"); } + } + spaceScratchActive = false; } return; } + if (!e.is("keyboard:down")) return; + + if (e.is("keyboard:down:escape")) { system?.jump?.("prompt"); return; } + // N: next track if (e.is("keyboard:down:n")) { trackIdx++; @@ -327,15 +364,36 @@ function paint({ wipe, ink, box, line, write, circle, screen, sound }) { // h-24: time / speed // h-32: progress bar - // Progress bar + // Waveform overview + playhead (falls back to a thin bar until the + // whole-track peaks finish decoding). const barY = h - 34; const barW = w - 8; const progress = d.duration > 0 ? d.position / d.duration : 0; - ink(T.bar[0], T.bar[1], T.bar[2]); - box(4, barY, barW, 4); - const pb = d.playing ? T.ok : [dim, dim + 20, dim]; - ink(pb[0], pb[1], pb[2]); - box(4, barY, Math.max(1, Math.floor(barW * progress)), 4); + if (!peaks && d.loaded) peaks = sound?.deck?.getPeaks?.(0) || null; + const playedX = 4 + Math.floor(barW * progress); + if (peaks && peaks.length > 0) { + const cyW = barY + 2; // vertical center of the strip + const HH = 11; // half-height (strip spans ~22px) + const pb = d.playing ? T.ok : [dim + 30, dim + 30, dim + 30]; + for (let x = 0; x < barW; x++) { + const pk = peaks[Math.floor((x / barW) * peaks.length)] || 0; + const colH = Math.max(1, Math.floor(pk * HH)); + const played = 4 + x < playedX; + if (played) ink(pb[0], pb[1], pb[2]); + else ink(T.bar[0], T.bar[1], T.bar[2]); + box(4 + x, cyW - colH, 1, colH * 2); + } + // Playhead. + ink(T.accent[0], T.accent[1], T.accent[2]); + box(Math.min(4 + barW - 1, playedX), cyW - HH, 1, HH * 2); + } else { + // Decoding (or stream without peaks) — thin progress bar. + ink(T.bar[0], T.bar[1], T.bar[2]); + box(4, barY, barW, 4); + const pb = d.playing ? T.ok : [dim, dim + 20, dim]; + ink(pb[0], pb[1], pb[2]); + box(4, barY, Math.max(1, Math.floor(barW * progress)), 4); + } // Time + speed ink(T.fgDim, T.fgDim, T.fgDim); @@ -376,10 +434,13 @@ function paint({ wipe, ink, box, line, write, circle, screen, sound }) { write(bd.label, { x: lx, y: ly, size: 1, font: F }); } - // Drag state - if (dragging) { + // Scratch state — radial drag or hold-space + trackpad-X. + if (dragging || spaceScratchActive) { ink(T.accent[0], T.accent[1], T.accent[2]); write("SCRATCH", { x: cx - 21, y: cy - 5, size: 1, font: F }); + } else if (spaceHeld) { + ink(T.warn[0], T.warn[1], T.warn[2]); + write("scrub ↔", { x: cx - 18, y: cy - 5, size: 1, font: F }); } // Message toast @@ -390,7 +451,36 @@ function paint({ wipe, ink, box, line, write, circle, screen, sound }) { } } -function sim({ system, sound }) { +function sim({ system, sound, pen }) { + const dk0 = sound?.deck; + const d0 = dk0?.decks?.[0]; + + // --- Hold-space + trackpad-X scratch --- + // While space is held, the pointer's horizontal velocity becomes the + // deck's playback speed: slide right = forward, left = reverse, hold + // still = silence. Released in act(). + if (spaceHeld && d0?.loaded && pen) { + const px = pen.x ?? 0; + if (scratchPrevX < 0) scratchPrevX = px; // seed on first frame + const dx = px - scratchPrevX; + scratchPrevX = px; + spaceMoved += Math.abs(dx); + if (spaceMoved > 3) spaceScratchActive = true; // past the tap threshold + if (spaceScratchActive) { + // Map px/frame to speed. ~6px/frame ≈ 1x; clamp to a hard scratch range. + let sp = dx / 6; + if (sp > 3) sp = 3; + if (sp < -3) sp = -3; + scratchSpeed = sp; + dk0.setSpeed(0, sp); + if (!d0.playing) dk0.play(0); // engine must run to render the scrub + } + } + + oldSim({ system, sound }); +} + +function oldSim({ system, sound }) { // USB hot-plug check every 3 seconds while active, or 15 seconds while idle. const mountPending = !!system?.mountMusicPending; const nowMounted = !!system?.mountMusicMounted; @@ -413,10 +503,20 @@ function sim({ system, sound }) { say(sound, "USB DJ off"); msg("USB removed"); } - // Auto-advance when track ends + // Auto-advance when the track reaches the end. Fires once per track + // (advancedForIdx guard) regardless of whether the deck flips `playing` + // to false exactly at EOF, and never mid-scratch/seek. Skipped while a + // scratch is in progress so scrubbing past the tail doesn't skip tracks. const d = sound?.deck?.decks?.[0]; - if (d?.loaded && !d.playing && d.position >= d.duration - 0.1 && d.duration > 0 && !dragging) { - trackIdx++; + const scratching = dragging || spaceScratchActive; + if ( + d?.loaded && d.duration > 0 && !scratching && + d.position >= d.duration - 0.25 && + advancedForIdx !== trackIdx && + files.length > 0 + ) { + advancedForIdx = trackIdx; // claim this track so we advance only once + trackIdx = (trackIdx + 1) % files.length; // wrap at the end of the crate loadTrack(sound); } } diff --git a/fedac/native/pieces/notepat.mjs b/fedac/native/pieces/notepat.mjs index ba9cf60372..64a8b182e1 100644 --- a/fedac/native/pieces/notepat.mjs +++ b/fedac/native/pieces/notepat.mjs @@ -462,7 +462,8 @@ let lastSpokenMsgKey = ""; // "from:text" of last TTS'd message (avoid repeats let wsStatus = ""; // "connecting" | "connected" | "error" | "" let wsConnectGrace = 0; // frames to wait before declaring error (race-condition guard) let wsReconnectTimer = 0; // frames until next reconnect attempt -let chatMuted = false; // mute TTS for incoming chat messages +let chatMuted = true; // incoming-chat TTS off by default on native + // (instrument-name / status speech is separate) let wifiWasConnected = false; let wifiConnectFrame = -9999; // frame when WiFi last connected (cooldown guard) let lastBatPercent = -1; // for battery change TTS diff --git a/fedac/native/pieces/os.mjs b/fedac/native/pieces/os.mjs index d7912bae65..30d2b280c0 100644 --- a/fedac/native/pieces/os.mjs +++ b/fedac/native/pieces/os.mjs @@ -2,10 +2,37 @@ // Shows current version, checks for updates, downloads + flashes + reboots. // Jumped to from prompt.mjs via "os" command or from notepat OS button. -const OS_BASE_URL = "https://releases-aesthetic-computer.sfo3.digitaloceanspaces.com/os/"; -const OS_VERSION_URL = OS_BASE_URL + "native-notepat-latest.version"; -const OS_VMLINUZ_URL = OS_BASE_URL + "native-notepat-latest.vmlinuz"; -const OS_INITRAMFS_URL = OS_BASE_URL + "native-notepat-latest.initramfs.cpio.gz"; +// Release source. Defaults to the DigitalOcean Spaces CDN the oven +// publishes to; resolveOsSource() can repoint it at a LAN dev host. +const OS_DEFAULT_BASE = "https://releases-aesthetic-computer.sfo3.digitaloceanspaces.com/os/"; +let OS_BASE_URL = OS_DEFAULT_BASE; +let OS_VERSION_URL = OS_BASE_URL + "native-notepat-latest.version"; +let OS_VMLINUZ_URL = OS_BASE_URL + "native-notepat-latest.vmlinuz"; +let OS_INITRAMFS_URL = OS_BASE_URL + "native-notepat-latest.initramfs.cpio.gz"; +let osSourceDev = false; // true once pointed at a LAN/dev override + +// Dev source override. If /mnt/os-source.txt exists on the EFI partition +// (first line = a base URL, e.g. "http://192.168.1.81:8888/os/"), OTA pulls +// the .version / .vmlinuz / .initramfs.cpio.gz from there instead of the +// CDN — flash a locally-built kernel over the LAN without a round trip +// through the oven + CDN. Public OTA images never carry this file, so the +// path is self-gating: production devices always use the CDN. Set it over +// ssh (`echo http://host:port/os/ > /mnt/os-source.txt`) and remove the +// file to return to CDN releases. +function resolveOsSource(system) { + try { + const raw = system?.readFile?.("/mnt/os-source.txt"); + if (!raw) return; + let base = raw.split("\n")[0].trim(); + if (!/^https?:\/\//i.test(base)) return; + if (!base.endsWith("/")) base += "/"; + OS_BASE_URL = base; + OS_VERSION_URL = base + "native-notepat-latest.version"; + OS_VMLINUZ_URL = base + "native-notepat-latest.vmlinuz"; + OS_INITRAMFS_URL = base + "native-notepat-latest.initramfs.cpio.gz"; + osSourceDev = true; + } catch (_) {} +} // POST install failures here so we can triage from MongoDB (collection // `os-install-reports`). See system/netlify/functions/os-install-report.mjs. const OS_REPORT_URL = "https://aesthetic.computer/api/os-install-report"; @@ -71,6 +98,44 @@ function setError(msg, fromState) { addTelemetry(`report queued: ${fromState} ${msg}`); } +// Kick off the kernel download → flash for the currently selected target. +// Shared by the `y` (install) and `b` (reflash boot usb) shortcuts. +function startKernelInstall(system) { + const targets = system?.flashTargets || []; + const tgt = targets[flashTargetIdx]; + globalThis.__osFlashDevice = tgt?.device || undefined; + progress = 0; + telemetry.length = 0; + // Preflight ESP space audit — refuse the install BEFORE downloading + // ~350 MB and BEFORE touching the partition if we can already see the + // ESP can't fit kernel + initramfs. Catches the historical brick where + // a tight ESP filled mid-write and the on-disk initramfs was silently + // truncated. Old kernels lacked diskFreeBytes, so guard with optional + // chaining and fall through (the C-side preflight catches it later). + const espMount = "/mnt"; // boot ESP is auto-mounted here + const espFree = system?.diskFreeBytes?.(espMount); + if (typeof espFree === "number" && espFree >= 0) { + const kernelNeed = remoteSize || 13_000_000; + const need = kernelNeed + OS_INITRAMFS_EXPECTED + OS_PREFLIGHT_MARGIN; + const freeMB = (espFree / 1048576).toFixed(0); + const needMB = (need / 1048576).toFixed(0); + addTelemetry(`preflight ESP=${espMount} free=${freeMB}MB need=${needMB}MB`); + if (espFree < need) { + addTelemetry("ABORT: insufficient ESP space for OTA"); + addTelemetry("hint: free space on the boot partition or USB-reflash"); + setError(`ESP only has ${freeMB}MB free (need ${needMB}MB)`, "preflight"); + return; + } + addTelemetry("preflight OK — starting download"); + } else { + addTelemetry("preflight: diskFreeBytes unavailable — skipping (C will retry)"); + } + state = "downloading"; + if (osSourceDev) addTelemetry("DEV SOURCE: " + OS_BASE_URL); + addTelemetry("fetching " + OS_VMLINUZ_URL.split("/").pop()); + system?.fetchBinary?.(OS_VMLINUZ_URL, "/tmp/vmlinuz.new", (remoteSize || 93_000_000)); +} + // Device manager state let deviceIdx = 0; let lastTargetCount = -1; // track hot-plug changes @@ -97,6 +162,7 @@ function hasCreds(system) { function boot({ system }) { currentVersion = system?.version || "unknown"; + resolveOsSource(system); // LAN dev override → /mnt/os-source.txt, else CDN // Default flash target: prefer non-boot device (e.g., NVMe when booting from USB) const targets = system?.flashTargets || []; const bootDev = system?.bootDevice; @@ -184,39 +250,23 @@ function act({ event: e, sound, system }) { // Touch-like key shortcuts for available state if (state === "available") { if (e.is("keyboard:down:y") || e.is("keyboard:down:enter") || e.is("keyboard:down:return")) { + startKernelInstall(system); + return; + } + // 'b' — one-touch reflash of the live boot device (the USB you're + // running from), skipping the target cycle. boot() defaults the + // selector to a non-boot disk, so without this updating the live stick + // means tabbing to it first. + if (e.is("keyboard:down:b")) { const targets = system?.flashTargets || []; - const tgt = targets[flashTargetIdx]; - const device = tgt?.device || undefined; - globalThis.__osFlashDevice = device; - progress = 0; - telemetry.length = 0; - // Preflight ESP space audit — refuse the install BEFORE downloading - // ~350 MB and BEFORE touching the partition if we can already see the - // ESP can't fit kernel + initramfs. Catches the historical brick where - // a tight ESP filled mid-write and the on-disk initramfs was silently - // truncated. Old kernels lacked diskFreeBytes, so guard with optional - // chaining and fall through (the C-side preflight catches it later). - const espMount = "/mnt"; // boot ESP is auto-mounted here - const espFree = system?.diskFreeBytes?.(espMount); - if (typeof espFree === "number" && espFree >= 0) { - const kernelNeed = remoteSize || 13_000_000; - const need = kernelNeed + OS_INITRAMFS_EXPECTED + OS_PREFLIGHT_MARGIN; - const freeMB = (espFree / 1048576).toFixed(0); - const needMB = (need / 1048576).toFixed(0); - addTelemetry(`preflight ESP=${espMount} free=${freeMB}MB need=${needMB}MB`); - if (espFree < need) { - addTelemetry("ABORT: insufficient ESP space for OTA"); - addTelemetry("hint: free space on the boot partition or USB-reflash"); - setError(`ESP only has ${freeMB}MB free (need ${needMB}MB)`, "preflight"); - return; - } - addTelemetry("preflight OK — starting download"); + const bootIdx = targets.findIndex(t => t.device === system?.bootDevice); + if (bootIdx >= 0) { + flashTargetIdx = bootIdx; + sound?.synth({ type: "triangle", tone: 660, duration: 0.08, volume: 0.12, attack: 0.003, decay: 0.06 }); + startKernelInstall(system); } else { - addTelemetry("preflight: diskFreeBytes unavailable — skipping (C will retry)"); + sound?.synth({ type: "square", tone: 180, duration: 0.12, volume: 0.1, attack: 0.004, decay: 0.1 }); } - state = "downloading"; - addTelemetry("fetching " + OS_VMLINUZ_URL.split("/").pop()); - system?.fetchBinary?.(OS_VMLINUZ_URL, "/tmp/vmlinuz.new", (remoteSize || 93_000_000)); return; } if (e.is("keyboard:down:n")) { @@ -479,6 +529,13 @@ function paint({ wipe, ink, box, line, write, screen, system, wifi }) { ink(T.fg, T.fg + 10, T.fg); write("ac/native", { x: pad, y: 10, size: 2, font: "matrix" }); + // Dev-source tag — visible in every state so a LAN-sourced flash is + // never mistaken for a CDN release. + if (osSourceDev) { + ink(255, 200, 60); + write("DEV SRC", { x: w - 52, y: 12, size: 1, font }); + } + // Connection status if (!wifi?.connected) { ink(T.err[0], T.err[1], T.err[2]); @@ -566,6 +623,12 @@ function paint({ wipe, ink, box, line, write, screen, system, wifi }) { write("install? y/n", { x: pad, y: hintY, size: 1, font }); ink(80, 80, 100); write("esc: back", { x: pad, y: hintY + 14, size: 1, font }); + // One-touch reflash of the live boot device. + const selBoot = targets[flashTargetIdx]?.device === system?.bootDevice; + if (!selBoot && targets.some(t => t.device === system?.bootDevice)) { + ink(60, 140, 200); + write("b: reflash boot usb", { x: pad, y: hintY + 28, size: 1, font }); + } } else if (state === "downloading") { ink(120, 140, 120); -- 2.51.2