Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

notepat: velocity-capture + pressure smoothing + NuPhy badge/gauge

Addresses two reported problems on the NuPhy HE keyboard:

1. Popping on sustained tones (esp. sine). Root cause: raw pressure
samples arrive at driver-specific ADC step rates and the sim loop
was writing every raw value straight into synth.update() each
frame, so discrete pressure steps produced audible clicks. Fix:
smooth pressure with a one-pole lowpass (α=0.20 ≈ 80ms time
constant) AND rate-limit synth.update() to changes > 0.5%. Retains
the expressive wobble; removes the pops.

2. Velocity not captured. Notes now stash their key-down pressure as
`entry.velocity`; the per-frame mix is `0.55·velocity + 0.45·
smoothed_pressure × master`. Initial hit dominates, live pressure
modulates — classic MIDI-like feel with a bit of aftertouch.

Also adds NuPhy presence detection + live sensor gauge:
- `lastAnalogKeyAt` / `lastAnalogPressure` bump on any key-down with
pressure strictly between 0 and 1 (digital keys stamp 1.0).
- Status bar renders a "nuphy" badge + two-row pressure gauge for 60s
after the last analog press. Top bar = echo of last press value
(fades over 2s); bottom bar = current max smoothed pressure across
all active analog notes. Tolerance ticks at 0.10 (soft threshold)
and 0.70 (high threshold) show where our envelope inflection points
are vs what the hardware is actually reporting.

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

+95 -3
+95 -3
fedac/native/pieces/notepat.mjs
··· 365 365 let wifiPasswordMode = false; // true = fullscreen password entry 366 366 let shiftHeld = false; 367 367 368 + // Analog keyboard (NuPhy Hall-Effect) detection. When a note-on event 369 + // arrives with an analog pressure value (0 < p < 1) that isn't the 370 + // default 1.0 stamped onto digital keypresses, we flag the session as 371 + // "analog". Rendered as a `nuphy` badge in the status bar so the user 372 + // knows velocity-sensitive play is active; stays sticky for 60 seconds 373 + // after the last analog press so a momentary digital press (e.g. the 374 + // laptop's built-in keyboard) doesn't flicker the badge off. 375 + let lastAnalogKeyAt = 0; // syncedNow() ms; 0 = never seen analog 376 + let lastAnalogPressure = 0; // last analog press value, for the debug gauge 377 + 368 378 // AC chat: latest message fetched after WiFi connects 369 379 let acMsg = null; // { from, text } once loaded 370 380 let lastSpokenMsgKey = ""; // "from:text" of last TTS'd message (avoid repeats on reconnect) ··· 1840 1850 if (!entry) return; 1841 1851 entry.midiNote = noteToMidiNumber(entry.note, entry.octave); 1842 1852 entry.midiChannel = 0; 1853 + // Stash the captured velocity on the entry so sim() can blend it with 1854 + // live pressure (55% velocity + 45% smoothed pressure). 1855 + entry.velocity = velocity; 1843 1856 sounds[key] = entry; 1844 1857 // While Enter is held, auto-latch new notes into heldKeys so they 1845 1858 // sustain on key-up. Otherwise notes release normally. ··· 2464 2477 // If this key is currently recording in per-key mode, suppress playback 2465 2478 if (perKeyRecording === key) return; 2466 2479 if (noteName && !sounds[key]) { 2480 + // NuPhy detection: analog pressure arrives as a float strictly between 2481 + // 0 and 1 (digital keys get the default 1.0 stamped on). Refresh the 2482 + // "last analog keypress" timestamp whenever we see one so the status 2483 + // bar badge + pressure-diagram overlay stay live. 2484 + if (e.pressure > 0 && e.pressure < 0.999) { 2485 + lastAnalogKeyAt = syncedNow(); 2486 + lastAnalogPressure = e.pressure; 2487 + } 2467 2488 const [letter, offset] = parseNote(noteName); 2468 2489 // Map parseNote offset to per-side octave: offset<=0 = left grid, offset>=1 = right grid 2469 2490 const sideOctaveAdj = offset >= 1 ? rightOctaveOffset : leftOctaveOffset; ··· 3325 3346 // either the QR or the text jumps to prompt. 3326 3347 globalThis.__npBtn = { x: 0, w: labelX + labelW, h: topBarH }; 3327 3348 3349 + // NuPhy badge + live sensor gauge. Active when we've seen an analog 3350 + // pressure value in the last 60 seconds. The gauge is two stacked 3351 + // horizontal bars: 3352 + // top: raw last-pressed value (fades with age) 3353 + // bottom: per-key-sum of currently-smoothed pressure (live jiggle) 3354 + // Gives a quick visual of the sensor input + our smoothing headroom. 3355 + const analogAgeMs = syncedNow() - lastAnalogKeyAt; 3356 + if (lastAnalogKeyAt > 0 && analogAgeMs < 60000) { 3357 + const badgeX = dotComX + 4 * 4 + 6; 3358 + // Label 3359 + ink(160, 220, 255, 220); 3360 + write("nuphy", { x: badgeX, y: barY, size: 1, font: "matrix" }); 3361 + // Live smoothed-pressure sum across active analog notes 3362 + let liveSmooth = 0, liveCount = 0; 3363 + for (const k of Object.keys(sounds)) { 3364 + const s = sounds[k]; 3365 + if (s && s.__pSmooth !== undefined) { 3366 + liveSmooth = Math.max(liveSmooth, s.__pSmooth); 3367 + liveCount++; 3368 + } 3369 + } 3370 + const gx = badgeX + 5 * 4 + 3; 3371 + const gw = 26; 3372 + const gy1 = 2, gy2 = 6; // two rows of 3px tall bars inside the 26px bar 3373 + // Fade the "last press" bar with age over 2 seconds so repeated 3374 + // presses refresh it visibly; leaves a faint echo of the last hit. 3375 + const echoAlpha = Math.max(0, 1.0 - analogAgeMs / 2000) * 255; 3376 + // Bar frames 3377 + ink(60, 60, 80, 160); 3378 + box(gx, gy1, gw, 3, true); 3379 + box(gx, gy2, gw, 3, true); 3380 + // Bar fills 3381 + ink(140, 200, 255, echoAlpha); 3382 + box(gx, gy1, Math.round(gw * lastAnalogPressure), 3, true); 3383 + ink(80, 220, 140, 220); 3384 + box(gx, gy2, Math.round(gw * liveSmooth), 3, true); 3385 + // Tolerance marks (thresholds we care about: 0.1 + 0.7) 3386 + ink(200, 150, 60, 180); 3387 + box(gx + Math.round(gw * 0.10), gy1, 1, 7, true); 3388 + ink(200, 80, 80, 180); 3389 + box(gx + Math.round(gw * 0.70), gy1, 1, 7, true); 3390 + } 3391 + 3328 3392 // Status area after notepat.com — auto-update indicator only (minimal) 3329 3393 let statusX = dotComX + 4 * 4 + 6; 3394 + // Shift statusX past the nuphy gauge when it's visible so indicators 3395 + // don't overlap. 3396 + if (lastAnalogKeyAt > 0 && analogAgeMs < 60000) statusX += 5 * 4 + 3 + 26 + 4; 3330 3397 const statusWrite = (text, r, g, b, a = 255) => { 3331 3398 if (!text) return false; 3332 3399 const width = text.length * 4; ··· 5407 5474 // Continuously update synth volumes from analog key pressure AND from 5408 5475 // the current per-side master volume so dragging the L/R master sliders 5409 5476 // live-adjusts sustained notes on that side. 5477 + // 5478 + // Pop mitigation: raw NuPhy pressure samples can jump between polls 5479 + // (HE keyboards sample at driver-specific rates and report discrete 5480 + // ADC steps). Writing those jumps straight to synth.update() caused 5481 + // audible clicks on sustained tones like `sine`. Instead we: 5482 + // 5483 + // 1. Capture velocity ONCE at key-down (entry.velocity) — sets the 5484 + // note's baseline loudness based on press impact. 5485 + // 2. Smooth the pressure-to-volume mapping with a one-pole lowpass 5486 + // (α ≈ 0.2, ~80ms time constant) so per-frame jumps glide 5487 + // instead of snapping. Keeps the expressive wobble that real 5488 + // pressure-sensitive play needs, drops the pops. 5489 + // 3. Blend: `vol = 0.55·velocity + 0.45·smoothed_pressure` — the 5490 + // initial hit energy dominates, live pressure just modulates. 5491 + // 4. Only push synth.update() when the new smoothed value has 5492 + // moved meaningfully (>0.5%) — avoids flooding the audio thread. 5493 + const PRESSURE_ALPHA = 0.20; 5410 5494 for (const key of Object.keys(sounds)) { 5411 5495 const entry = sounds[key]; 5412 5496 if (!entry?.synth) continue; 5413 5497 const master = entry.gridOffset === 1 ? rightMasterVol : leftMasterVol; 5414 5498 const p = pressures?.[key]; // 0.0-1.0 analog pressure, undefined if not analog 5415 5499 if (p !== undefined) { 5416 - const vol = p * 0.7 * master; // Linear: 0 pressure = silence, full press × master 5417 - if (entry.__lastVol !== vol) { 5500 + // Smoothed pressure (lowpassed to remove ADC-step pops) 5501 + const prevSmooth = entry.__pSmooth ?? p; 5502 + const pSmooth = prevSmooth + (p - prevSmooth) * PRESSURE_ALPHA; 5503 + entry.__pSmooth = pSmooth; 5504 + // Velocity captured at key-down (falls back to baseVol default). 5505 + const velocity = entry.velocity ?? 1.0; 5506 + // 55% velocity baseline + 45% smoothed-pressure wobble, scaled by 5507 + // master. Full-press at velocity=1 + master=1 = 1.0 peak. 5508 + const vol = (velocity * 0.55 + pSmooth * 0.45) * master; 5509 + if (entry.__lastVol === undefined || Math.abs(entry.__lastVol - vol) > 0.005) { 5418 5510 entry.synth.update({ volume: vol }); 5419 5511 entry.__lastVol = vol; 5420 5512 } 5421 - if (trail[key]) trail[key].brightness = p; 5513 + if (trail[key]) trail[key].brightness = pSmooth; 5422 5514 } else { 5423 5515 // Digital key — only react to master volume changes, not pressure. 5424 5516 if (entry.__lastMaster !== master) {