diff --git a/viz/index.html b/viz/index.html
index 5bef08b..6924d07 100644
--- a/viz/index.html
+++ b/viz/index.html
@@ -383,6 +383,14 @@
#testament.on { color: #ff8a8a !important; border-color: rgba(240,110,110,0.6) !important;
background: rgba(230,90,90,0.2) !important; animation: gspk-pulse 1.5s ease-in-out infinite; font-variant-numeric: tabular-nums; }
+ /* π PSALM β the listening chip; shows the note you're singing, brightens on voice */
+ #psalmchip { position: fixed; bottom: 164px; right: 14px; z-index: 12; display: none;
+ align-items: center; gap: 4px; min-width: 46px; padding: 6px 12px; border-radius: 8px; font-size: 12px;
+ background: rgba(20,28,44,0.7); color: #8fa4c8; border: 1px solid rgba(120,150,200,0.25);
+ cursor: pointer; -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
+ #psalmchip.open { display: inline-flex; }
+ #psalmchip.voiced { color: #cfe4ff; border-color: rgba(150,200,255,0.6); background: rgba(60,95,150,0.5); }
+
/* right-click context menu β every setting, on everything */
#ctxmenu {
position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh;
@@ -3180,6 +3188,119 @@
godspeedEl.appendChild(hint);
}
+ // ---- π PSALM β sing, and the instrument answers ------------------------
+ // Mic pitch, folded to the global scale, played as notes β or, when β‘ GODSPEED
+ // is on, latched into the arp one note at a time (sing your chord in). PRIVACY:
+ // analysis is entirely local, in a THROWAWAY AudioContext the music graph never
+ // touches β zero feedback risk, and nothing ever leaves the page. This block
+ // only consumes Synth.pluck / arpLatch; it reaches into no engine.
+ const PSALM_SENS = 0.012; // RMS gate β quieter than this is "silence"
+ const PSALM_CLARITY = 0.6; // autocorrelation confidence floor
+ // Pure pitch detector over a Float32Array β unit-testable without a mic.
+ function psalmDetect(buf, sampleRate) {
+ const n = buf.length;
+ let rms = 0; for (let i = 0; i < n; i++) rms += buf[i] * buf[i];
+ rms = Math.sqrt(rms / n);
+ if (rms < 1e-4) return { freq: 0, clarity: 0, rms };
+ const minLag = Math.floor(sampleRate / 1000), maxLag = Math.floor(sampleRate / 80); // 80β1000 Hz
+ let c0 = 0; for (let i = 0; i < n; i++) c0 += buf[i] * buf[i];
+ const acAt = (L) => { let a = 0; for (let i = 0; i + L < n; i++) a += buf[i] * buf[i + L]; return a; };
+ let bestLag = -1, bestVal = 0;
+ for (let lag = minLag; lag <= maxLag && lag < n; lag++) { const ac = acAt(lag); if (ac > bestVal) { bestVal = ac; bestLag = lag; } }
+ if (bestLag < 0) return { freq: 0, clarity: 0, rms };
+ const clarity = c0 > 0 ? bestVal / c0 : 0;
+ let lag = bestLag; // parabolic interpolation for sub-sample accuracy
+ if (bestLag > minLag && bestLag < maxLag) {
+ const y0 = acAt(bestLag - 1), y1 = bestVal, y2 = acAt(bestLag + 1), den = (y0 - 2 * y1 + y2);
+ if (den !== 0) lag = bestLag + 0.5 * (y0 - y2) / den;
+ }
+ return { freq: sampleRate / lag, clarity, rms };
+ }
+ const freqToMidi = (f) => 69 + 12 * Math.log2(f / 440);
+ function psalmFold(midiFloat) { // fold to the nearest note of the global scale
+ const iv = SCALES[gScale.name] || SCALES.minor_pent;
+ const pcs = iv.map(i => (((gScale.root + i) % 12) + 12) % 12);
+ const c = Math.round(midiFloat); let best = c, bestD = 1e9;
+ for (let k = c - 7; k <= c + 7; k++) {
+ if (pcs.indexOf(((k % 12) + 12) % 12) < 0) continue;
+ const d = Math.abs(k - midiFloat); if (d < bestD) { bestD = d; best = k; }
+ }
+ return best;
+ }
+ const psalm = { on: false, ctx: null, an: null, stream: null, timer: null,
+ lastNote: -1, stableNote: -1, stableCount: 0, gateOpen: false, silentMs: 0, curName: "" };
+ // Pure note-logic state machine (testable): a NEW note fires once after 2 stable
+ // frames; legato re-fires on a note change; after ~150ms below the gate it closes
+ // and the same note may re-attack. Velocity from RMS.
+ function psalmStep(d, dtMs) {
+ const out = { fire: false, note: -1, vel: 0 };
+ const voiced = d.rms >= PSALM_SENS && d.clarity >= PSALM_CLARITY && d.freq > 0;
+ if (!voiced) {
+ psalm.silentMs += dtMs;
+ if (psalm.silentMs >= 150) { psalm.gateOpen = false; psalm.lastNote = -1; psalm.stableNote = -1; psalm.stableCount = 0; }
+ return out;
+ }
+ psalm.silentMs = 0;
+ const note = psalmFold(freqToMidi(d.freq));
+ if (note === psalm.stableNote) psalm.stableCount++;
+ else { psalm.stableNote = note; psalm.stableCount = 1; }
+ if (psalm.stableCount < 2) return out; // hold two frames before committing
+ if (note !== psalm.lastNote) { // a new note, or a legato change
+ psalm.lastNote = note; psalm.gateOpen = true;
+ out.fire = true; out.note = note; out.vel = clamp(0.3 + d.rms * 4, 0.3, 1);
+ }
+ return out;
+ }
+ let psalmChip = null;
+ function psalmChipEnsure() {
+ if (psalmChip) return psalmChip;
+ psalmChip = document.createElement("div"); psalmChip.id = "psalmchip";
+ psalmChip.title = "π PSALM β singing; click to stop listening";
+ psalmChip.onclick = () => psalmSet(false);
+ document.body.appendChild(psalmChip);
+ return psalmChip;
+ }
+ function psalmPaint() {
+ if (!psalmChip) return;
+ psalmChip.classList.toggle("open", psalm.on);
+ psalmChip.classList.toggle("voiced", psalm.gateOpen);
+ psalmChip.textContent = "π " + (psalm.on ? (psalm.curName || "β¦") : "");
+ }
+ async function psalmSet(v) {
+ if (v && psalm.on) return;
+ if (!v) { // stop + fully release the mic
+ psalm.on = false;
+ if (psalm.timer) { clearInterval(psalm.timer); psalm.timer = null; }
+ if (psalm.stream) { psalm.stream.getTracks().forEach(t => t.stop()); psalm.stream = null; }
+ if (psalm.ctx) { try { psalm.ctx.close(); } catch (e) {} psalm.ctx = null; }
+ psalm.gateOpen = false; psalm.curName = ""; psalmPaint(); return;
+ }
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } });
+ const AC = window.AudioContext || window.webkitAudioContext;
+ const actx = new AC(); // OWN context β mic is analysed only, never routed to the music
+ const src = actx.createMediaStreamSource(stream);
+ const an = actx.createAnalyser(); an.fftSize = 2048; src.connect(an);
+ const buf = new Float32Array(an.fftSize), dtMs = 25;
+ Object.assign(psalm, { on: true, stream, ctx: actx, an, lastNote: -1, stableNote: -1, stableCount: 0, gateOpen: false, silentMs: 0 });
+ psalmChipEnsure(); psalmPaint();
+ psalm.timer = setInterval(() => { // setInterval, not rAF β a hidden tab shouldn't freeze the ear
+ an.getFloatTimeDomainData(buf);
+ const r = psalmStep(psalmDetect(buf, actx.sampleRate), dtMs);
+ if (r.fire && r.note >= 0) {
+ psalm.curName = midiToName(r.note);
+ if (arp.on) arpLatch(r.note, r.vel); else Synth.pluck(r.note, r.vel);
+ }
+ psalmPaint();
+ }, dtMs);
+ } catch (e) {
+ psalm.on = false;
+ eventTicker.unshift({ text: "π the instrument cannot hear you β allow the microphone", tAdded: now(), src: "sys" });
+ psalmPaint();
+ }
+ }
+ function togglePsalm() { psalmSet(!psalm.on); }
+
// the one place a drum voice sounds β shared by seqTick AND the finger-drum keys (E.1),
// so the sequenced path and the played path can never drift: same guard, same GM MIDI.
function fireDrum(name, vel, durMs) {
@@ -7835,6 +7956,7 @@
ctxItem((Synth.testamentState && Synth.testamentState().on) ? "πΌ TESTAMENT β stop & save the take" : "πΌ TESTAMENT β record what god does",
() => { if (Synth.testament) Synth.testament(); });
ctxItem("β‘ GODSPEED β hold a chord, the machine runs", () => openGodspeed());
+ ctxItem((psalm.on ? "π PSALM β listeningβ¦ (click to stop)" : "π PSALM β sing, and the instrument answers"), () => togglePsalm());
ctxItem("π " + (Synth.feeding() ? "tab feed: live β click to stop" : "feed a tab (YouTube, a streamβ¦) into the world"),
async () => { const r = await Synth.feedTab();
const msg = r.stopped ? "π tab feed stopped"