From cccbce536c0cad35bccb78e4e4354bc8aa215cab Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 14 Jul 2026 14:04:28 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=95=8A=20revelation:=20PSALM=20=E2=80=94?= =?UTF-8?q?=20sing,=20and=20the=20instrument=20answers=20(Stage=202P)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mic pitch → folded to the global scale → played as notes, or (when ⚡ GODSPEED is on) latched into the arp one note at a time. The mic lives in its OWN throwaway AudioContext — it is analysed, never routed into the music graph (zero feedback), and nothing leaves the page. A pure autocorrelation detector psalmDetect(buf,sr)→{freq,clarity,rms} (80–1000 Hz, parabolic interpolation) and a pure note state-machine psalmStep() (2 stable frames to fire, legato on change, 150ms gate close + re-attack, velocity from RMS) — both unit-tested headlessly. A 🕊 chip shows the sung note and brightens on voice; a ctxItem toggles it; mic denial tickers "the instrument cannot hear you". Verified: detector 220/440/554Hz within 0.09% (silence & noise gated), fold lands in-scale, the state machine single-fires / legatos / re-attacks / scales velocity, the ctxItem + mic-denial path work live, zero console errors. Co-Authored-By: Claude Fable 5 --- viz/index.html | 122 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) 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"