🕊 revelation: PSALM — sing, and the instrument answers (Stage 2P)

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-14 14:04:28 +10:00
parent 0ff4259a4f
commit cccbce536c

View File

@ -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); // 801000 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"