Skip to content

Instantly share code, notes, and snippets.

@brasic
Created April 5, 2026 04:45
Show Gist options
  • Select an option

  • Save brasic/ca56303ce3510ca49ecffdb7a57805b1 to your computer and use it in GitHub Desktop.

Select an option

Save brasic/ca56303ce3510ca49ecffdb7a57805b1 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Piano Puzzle</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Georgia', 'Times New Roman', serif;
background: #0a0a14;
color: #e8e0d4;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.container {
text-align: center;
padding: 2rem;
max-width: 600px;
width: 100%;
position: relative;
}
/* Stars background */
.stars {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
z-index: 0;
}
.star {
position: absolute;
width: 2px;
height: 2px;
background: #fff;
border-radius: 50%;
animation: twinkle 3s ease-in-out infinite alternate;
}
@keyframes twinkle {
0% { opacity: 0.2; }
100% { opacity: 0.8; }
}
.container > * { position: relative; z-index: 1; }
/* Landing state */
h1 {
font-size: 1.6rem;
font-weight: normal;
color: #c9a0dc;
margin-bottom: 1rem;
line-height: 1.5;
font-style: italic;
}
.landing-instructions {
font-size: 0.95rem;
color: #a0a0b8;
margin-bottom: 1.5rem;
line-height: 1.6;
}
.begin-btn {
background: none;
border: 2px solid #c9a0dc;
color: #c9a0dc;
font-family: inherit;
font-size: 1.2rem;
padding: 0.8rem 2.5rem;
border-radius: 40px;
cursor: pointer;
transition: all 0.3s ease;
letter-spacing: 0.05em;
}
.begin-btn:hover {
background: rgba(201, 160, 220, 0.15);
box-shadow: 0 0 20px rgba(201, 160, 220, 0.3);
}
/* Listening state */
.listening-area { display: none; }
.mic-indicator {
width: 80px;
height: 80px;
border-radius: 50%;
border: 2px solid rgba(201, 160, 220, 0.5);
margin: 0 auto 1.5rem;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.mic-indicator.active {
animation: pulse-ring 2s ease-out infinite;
}
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(201, 160, 220, 0.4); }
70% { box-shadow: 0 0 0 20px rgba(201, 160, 220, 0); }
100% { box-shadow: 0 0 0 0 rgba(201, 160, 220, 0); }
}
.mic-icon {
font-size: 2rem;
opacity: 0.8;
}
.challenge-label {
font-size: 1rem;
color: #c9a0dc;
margin-bottom: 1rem;
font-style: italic;
}
/* Music staff */
.staff-container {
margin: 0 auto 1.5rem;
display: flex;
justify-content: center;
}
.staff-container svg {
filter: drop-shadow(0 0 8px rgba(201, 160, 220, 0.15));
}
/* Note glow effects applied via JS filter */
.note-glow-green {
filter: drop-shadow(0 0 6px rgba(126, 232, 126, 0.6));
}
.note-glow-red {
filter: drop-shadow(0 0 6px rgba(255, 100, 80, 0.6));
}
@keyframes gentle-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
/* Detection feedback */
.feedback-area {
min-height: 3.5rem;
margin-bottom: 0.5rem;
}
.detected-note {
font-size: 0.9rem;
color: rgba(160, 160, 184, 0.5);
height: 1.5rem;
transition: all 0.2s;
}
.wrong-message {
font-size: 1rem;
color: #ff8c64;
height: 1.5rem;
opacity: 0;
transition: opacity 0.3s;
}
.wrong-message.visible {
opacity: 1;
}
/* Wrong note screen shake */
@keyframes shake {
0%, 100% { transform: translateX(0); }
15% { transform: translateX(-8px); }
30% { transform: translateX(8px); }
45% { transform: translateX(-6px); }
60% { transform: translateX(6px); }
75% { transform: translateX(-3px); }
90% { transform: translateX(3px); }
}
.shaking {
animation: shake 0.5s ease-out;
}
/* Wrong note background flash */
.wrong-flash {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(255, 80, 60, 0.15);
pointer-events: none;
z-index: 5;
opacity: 0;
transition: opacity 0.15s;
}
.wrong-flash.active {
opacity: 1;
}
/* Hold progress bar */
.hold-bar-container {
width: 120px;
height: 4px;
background: rgba(201, 160, 220, 0.15);
border-radius: 2px;
margin: 0 auto 1rem;
overflow: hidden;
opacity: 0;
transition: opacity 0.2s;
}
.hold-bar-container.visible { opacity: 1; }
.hold-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #c9a0dc, #7ee87e);
border-radius: 2px;
transition: width 0.05s linear;
}
/* Success state */
.success-area {
display: none;
}
.success-title {
font-size: 1.8rem;
color: #7ee87e;
margin-bottom: 1.5rem;
opacity: 0;
transform: translateY(20px);
animation: reveal-up 0.8s ease-out 0.3s forwards;
}
.lock-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0;
animation: lock-open 1s ease-out 0.1s forwards;
}
@keyframes lock-open {
0% { opacity: 0; transform: scale(0.5) rotate(-10deg); }
50% { opacity: 1; transform: scale(1.2) rotate(5deg); }
100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
.clue-text {
font-size: 1.3rem;
color: #f0e6d6;
line-height: 1.7;
padding: 1.5rem 2rem;
border: 1px solid rgba(126, 232, 126, 0.4);
border-radius: 12px;
background: rgba(126, 232, 126, 0.05);
opacity: 0;
transform: translateY(20px);
animation: reveal-up 0.8s ease-out 0.8s forwards;
}
.clue-label {
font-size: 0.8rem;
color: rgba(126, 232, 126, 0.6);
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 0.5rem;
opacity: 0;
animation: reveal-up 0.6s ease-out 0.6s forwards;
}
@keyframes reveal-up {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Sparkle particles for success */
.sparkle {
position: fixed;
width: 6px;
height: 6px;
border-radius: 50%;
pointer-events: none;
z-index: 10;
}
@keyframes sparkle-rise {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(-200px) scale(0);
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
</style>
</head>
<body>
<div class="stars" id="stars"></div>
<div class="wrong-flash" id="wrongFlash"></div>
<div class="container" id="mainContainer">
<!-- Landing State -->
<div class="landing-area" id="landing">
<h1>The Bunny left a message<br>in the music&hellip;</h1>
<p class="landing-instructions">
Play the melody on the card &mdash; all four notes,<br>
without mistakes or gaps.
</p>
<button class="begin-btn" id="beginBtn">Begin Listening</button>
</div>
<!-- Listening State -->
<div class="listening-area" id="listening">
<div class="mic-indicator active" id="micIndicator">
<span class="mic-icon">&#127911;</span>
</div>
<p class="challenge-label">Play the melody perfectly!</p>
<div class="staff-container" id="staffContainer"></div>
<div class="hold-bar-container" id="holdBarContainer">
<div class="hold-bar" id="holdBar"></div>
</div>
<div class="feedback-area">
<p class="detected-note" id="detectedNote">&nbsp;</p>
<p class="wrong-message" id="wrongMessage">Wrong note! Starting over&hellip;</p>
</div>
</div>
<!-- Success State -->
<div class="success-area" id="success">
<div class="lock-icon">&#128275;</div>
<div class="success-title">&#10024; Melody unlocked! &#10024;</div>
<p class="clue-label">Your next puzzle pieces are hidden here:</p>
<div class="clue-text" id="clueText"></div>
</div>
</div>
<script>
// ============================================================
// CONFIGURATION — Change these to customize the puzzle
// ============================================================
// The melody to play. Each entry: [internalNote, displayLabel]
const TARGET_SEQUENCE = [
['C4', 'Middle C'],
['E4', 'E'],
['G4', 'G'],
['A4', 'A'],
['G4', 'G'],
['E4', 'E'],
['C5', 'High C'],
];
// What the success screen reveals — the location of Puzzle Bag E
const CLUE_TEXT = 'Check the drawer of the table Daddy built \u2014 all the way in the back.';
// Detection tuning
const CENTS_TOLERANCE = 50; // Accept within ±50 cents of target
const HOLD_DURATION_MS = 200; // Note must be held for this long
const AMPLITUDE_THRESHOLD = 0.02; // Ignore sounds quieter than this (0-1)
const SILENCE_RESET_MS = 4000; // Reset progress after this much silence
// ============================================================
// Note name -> frequency lookup (all piano notes, octaves 1-7)
const NOTE_FREQUENCIES = {};
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
for (let midi = 24; midi <= 96; midi++) {
const octave = Math.floor(midi / 12) - 1;
const name = NOTE_NAMES[midi % 12] + octave;
NOTE_FREQUENCIES[name] = 440 * Math.pow(2, (midi - 69) / 12);
}
// Target frequencies derived from note names
const targetFrequencies = TARGET_SEQUENCE.map(([n]) => NOTE_FREQUENCIES[n]);
// State
let currentNoteIndex = 0;
let holdStartTime = null;
let holdingCorrectNote = false;
let lastSoundTime = null;
let audioContext = null;
let analyser = null;
let animationFrameId = null;
let resetCooldown = false;
let lastNoteConfirmedTime = 0; // grace period after confirming a note
let wrongNoteStartTime = null; // track sustained wrong notes
let wrongNoteName = null; // which wrong note is being sustained
const GRACE_PERIOD_MS = 600; // ignore wrong notes for this long after confirming
const WRONG_NOTE_SUSTAIN_MS = 400; // wrong note must be held this long to trigger reset
// DOM
const landing = document.getElementById('landing');
const listeningArea = document.getElementById('listening');
const successArea = document.getElementById('success');
const beginBtn = document.getElementById('beginBtn');
const staffContainer = document.getElementById('staffContainer');
const detectedNote = document.getElementById('detectedNote');
const clueText = document.getElementById('clueText');
const holdBarContainer = document.getElementById('holdBarContainer');
const holdBar = document.getElementById('holdBar');
const wrongMessage = document.getElementById('wrongMessage');
const wrongFlash = document.getElementById('wrongFlash');
const mainContainer = document.getElementById('mainContainer');
// SVG music staff constants
// Staff line spacing = 12px. 5 lines from y=30 to y=78.
// Line positions (top to bottom): 30, 42, 54, 66, 78
// Note Y positions on treble clef:
// C5 = 48 (3rd space, between lines at 42 and 54)
// G4 = 66 (2nd line)
// E4 = 78 (1st line, bottom)
// C4 = 90 (below staff, needs ledger line)
const STAFF_LINE_Y = [30, 42, 54, 66, 78];
const LINE_SPACING = 12;
const NOTE_Y_MAP = { 'C4': 90, 'D4': 84, 'E4': 78, 'F4': 72, 'G4': 66, 'A4': 60, 'B4': 54, 'C5': 48 };
const NOTE_RX = 8; // notehead horizontal radius
const NOTE_RY = 6; // notehead vertical radius
const STEM_LENGTH = 36;
const SVG_W = 400;
const SVG_H = 120;
const STAFF_X_START = 50; // after clef
const STAFF_X_END = SVG_W - 10;
const NOTE_COLOR_DEFAULT = 'rgba(201, 160, 220, 0.7)';
const NOTE_COLOR_ACTIVE = '#c9a0dc';
const NOTE_COLOR_CORRECT = '#7ee87e';
const NOTE_COLOR_WRONG = '#ff6b6b';
// Noteheads stored here for coloring
let noteElements = [];
// Generate starfield
(function createStars() {
const container = document.getElementById('stars');
for (let i = 0; i < 60; i++) {
const star = document.createElement('div');
star.className = 'star';
star.style.left = Math.random() * 100 + '%';
star.style.top = Math.random() * 100 + '%';
star.style.animationDelay = (Math.random() * 3) + 's';
star.style.animationDuration = (2 + Math.random() * 2) + 's';
if (Math.random() > 0.7) {
star.style.width = '3px';
star.style.height = '3px';
}
container.appendChild(star);
}
})();
function svgEl(tag, attrs) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
return el;
}
// Build SVG music staff with treble clef and notes
function buildStaff() {
staffContainer.innerHTML = '';
noteElements = [];
const svg = svgEl('svg', {
viewBox: `0 0 ${SVG_W} ${SVG_H}`,
width: SVG_W,
height: SVG_H,
});
// Staff lines
for (const ly of STAFF_LINE_Y) {
svg.appendChild(svgEl('line', {
x1: STAFF_X_START, y1: ly, x2: STAFF_X_END, y2: ly,
stroke: 'rgba(201, 160, 220, 0.3)', 'stroke-width': 1.2,
}));
}
// Treble clef (Unicode, positioned at left of staff)
const clef = svgEl('text', {
x: STAFF_X_START - 2, y: 74,
'font-size': '56px',
fill: 'rgba(201, 160, 220, 0.6)',
'font-family': 'serif',
'text-anchor': 'middle',
});
clef.textContent = '\u{1D11E}';
svg.appendChild(clef);
// Notes — evenly spaced horizontally
const noteCount = TARGET_SEQUENCE.length;
const noteAreaStart = STAFF_X_START + 50;
const noteAreaEnd = STAFF_X_END - 20;
const noteSpacing = (noteAreaEnd - noteAreaStart) / (noteCount - 1);
TARGET_SEQUENCE.forEach(([noteName], i) => {
const nx = noteAreaStart + i * noteSpacing;
const ny = NOTE_Y_MAP[noteName];
const g = svgEl('g', { id: 'note-g-' + i });
// Ledger line for Middle C (below staff)
if (noteName === 'C4') {
g.appendChild(svgEl('line', {
x1: nx - NOTE_RX - 5, y1: ny, x2: nx + NOTE_RX + 5, y2: ny,
stroke: 'rgba(201, 160, 220, 0.3)', 'stroke-width': 1.2,
}));
}
// Stem (up for notes on or below middle line, down for above)
const middleLineY = STAFF_LINE_Y[2]; // 54
const stemUp = ny >= middleLineY;
const stemX = stemUp ? nx + NOTE_RX - 1 : nx - NOTE_RX + 1;
const stemY1 = ny;
const stemY2 = stemUp ? ny - STEM_LENGTH : ny + STEM_LENGTH;
const stem = svgEl('line', {
x1: stemX, y1: stemY1, x2: stemX, y2: stemY2,
stroke: NOTE_COLOR_DEFAULT, 'stroke-width': 1.8,
});
g.appendChild(stem);
// Notehead (filled ellipse, slightly tilted)
const head = svgEl('ellipse', {
cx: nx, cy: ny, rx: NOTE_RX, ry: NOTE_RY,
fill: NOTE_COLOR_DEFAULT,
transform: `rotate(-15, ${nx}, ${ny})`,
});
g.appendChild(head);
svg.appendChild(g);
noteElements.push({ head, stem, g });
});
staffContainer.appendChild(svg);
}
function setNoteColor(i, color, glow) {
if (i < 0 || i >= noteElements.length) return;
const { head, stem, g } = noteElements[i];
head.setAttribute('fill', color);
stem.setAttribute('stroke', color);
g.classList.remove('note-glow-green', 'note-glow-red');
if (glow) g.classList.add(glow);
}
function updateProgress() {
for (let i = 0; i < TARGET_SEQUENCE.length; i++) {
if (i < currentNoteIndex) {
setNoteColor(i, NOTE_COLOR_CORRECT, 'note-glow-green');
} else if (i === currentNoteIndex) {
setNoteColor(i, NOTE_COLOR_ACTIVE, null);
} else {
setNoteColor(i, NOTE_COLOR_DEFAULT, null);
}
}
}
// Reset all progress to the beginning (wrong note or gap)
function resetProgress(showWrongNote) {
if (currentNoteIndex === 0) return;
if (resetCooldown) return;
resetCooldown = true;
resetHold();
if (showWrongNote) {
// Screen shake
mainContainer.classList.remove('shaking');
void mainContainer.offsetWidth;
mainContainer.classList.add('shaking');
// Red flash overlay
wrongFlash.classList.add('active');
setTimeout(() => wrongFlash.classList.remove('active'), 300);
// Flash all completed notes red briefly
for (let i = 0; i < currentNoteIndex; i++) {
setNoteColor(i, NOTE_COLOR_WRONG, 'note-glow-red');
}
// Wrong note message
wrongMessage.classList.add('visible');
setTimeout(() => wrongMessage.classList.remove('visible'), 2000);
}
currentNoteIndex = 0;
// Brief delay before resetting note colors
setTimeout(() => {
updateProgress();
resetCooldown = false;
}, 800);
}
// Frequency -> nearest note name + cents offset
function frequencyToNote(freq) {
const midi = 69 + 12 * Math.log2(freq / 440);
const roundedMidi = Math.round(midi);
const octave = Math.floor(roundedMidi / 12) - 1;
const name = NOTE_NAMES[((roundedMidi % 12) + 12) % 12] + octave;
return { name, midi: roundedMidi, freq };
}
// Friendly display name for any detected note
function friendlyNoteName(noteName) {
if (noteName === 'C4') return 'Middle C';
// Strip octave number for display
return noteName.replace(/\d+$/, '').replace('#', '\u266F');
}
// Check if detected frequency is within tolerance of target
function isNoteMatch(detectedFreq, targetFreq) {
const centsOff = 1200 * Math.log2(detectedFreq / targetFreq);
return Math.abs(centsOff) <= CENTS_TOLERANCE;
}
// Check if detected frequency matches ANY note in the sequence
function isAnySequenceNote(detectedFreq) {
return targetFrequencies.some(tf => isNoteMatch(detectedFreq, tf));
}
// Autocorrelation pitch detection
function autoCorrelate(buf, sampleRate) {
let rms = 0;
for (let i = 0; i < buf.length; i++) {
rms += buf[i] * buf[i];
}
rms = Math.sqrt(rms / buf.length);
if (rms < AMPLITUDE_THRESHOLD) return -1;
const SIZE = buf.length;
const MAX_SAMPLES = Math.floor(SIZE / 2);
let bestOffset = -1;
let bestCorrelation = 0;
let foundGoodCorrelation = false;
const correlations = new Float32Array(MAX_SAMPLES);
for (let offset = 0; offset < MAX_SAMPLES; offset++) {
let correlation = 0;
for (let i = 0; i < MAX_SAMPLES; i++) {
correlation += buf[i] * buf[i + offset];
}
correlations[offset] = correlation;
}
const c0 = correlations[0];
if (c0 === 0) return -1;
for (let i = 0; i < MAX_SAMPLES; i++) {
correlations[i] /= c0;
}
let dipped = false;
const CORRELATION_THRESHOLD = 0.9;
for (let offset = 1; offset < MAX_SAMPLES; offset++) {
if (correlations[offset] < 0.5) {
dipped = true;
}
if (dipped && correlations[offset] > CORRELATION_THRESHOLD) {
if (correlations[offset] > bestCorrelation) {
bestCorrelation = correlations[offset];
bestOffset = offset;
foundGoodCorrelation = true;
}
}
if (foundGoodCorrelation && correlations[offset] < bestCorrelation - 0.05) {
break;
}
}
if (!foundGoodCorrelation || bestOffset < 1) return -1;
const prev = correlations[bestOffset - 1];
const curr = correlations[bestOffset];
const next = bestOffset + 1 < MAX_SAMPLES ? correlations[bestOffset + 1] : curr;
const shift = (next - prev) / (2 * (2 * curr - next - prev));
const refinedOffset = bestOffset + (isFinite(shift) ? shift : 0);
return sampleRate / refinedOffset;
}
// Main detection loop
function detectPitch() {
if (!analyser) return;
const bufferLength = analyser.fftSize;
const float32 = new Float32Array(bufferLength);
analyser.getFloatTimeDomainData(float32);
const freq = autoCorrelate(float32, audioContext.sampleRate);
const now = performance.now();
if (freq > 0) {
lastSoundTime = now;
const noteInfo = frequencyToNote(freq);
detectedNote.textContent = friendlyNoteName(noteInfo.name);
const targetFreq = targetFrequencies[currentNoteIndex];
if (isNoteMatch(freq, targetFreq)) {
// Correct note — reset wrong-note tracking
wrongNoteStartTime = null;
wrongNoteName = null;
if (!holdingCorrectNote) {
holdingCorrectNote = true;
holdStartTime = now;
holdBarContainer.classList.add('visible');
}
const elapsed = now - holdStartTime;
const progress = Math.min(elapsed / HOLD_DURATION_MS, 1);
holdBar.style.width = (progress * 100) + '%';
if (elapsed >= HOLD_DURATION_MS) {
onNoteConfirmed();
}
} else {
// Non-matching pitch detected
if (holdingCorrectNote) {
resetHold();
}
// Skip wrong-note punishment during grace period after a confirmed note
// (piano sustain/decay produces spurious pitches as the note fades)
const inGracePeriod = (now - lastNoteConfirmedTime) < GRACE_PERIOD_MS;
if (currentNoteIndex > 0 && !inGracePeriod && !resetCooldown) {
// Require the wrong note to be sustained before resetting.
// If the detected note changed, restart the wrong-note timer.
if (noteInfo.name !== wrongNoteName) {
wrongNoteName = noteInfo.name;
wrongNoteStartTime = now;
} else if (now - wrongNoteStartTime >= WRONG_NOTE_SUSTAIN_MS) {
wrongNoteStartTime = null;
wrongNoteName = null;
resetProgress(true);
}
}
}
} else {
// Silence — clear wrong-note tracking
if (holdingCorrectNote) {
resetHold();
}
wrongNoteStartTime = null;
wrongNoteName = null;
detectedNote.textContent = '\u00a0';
// Check for silence timeout — reset if too long a gap mid-melody
if (currentNoteIndex > 0 && lastSoundTime && (now - lastSoundTime > SILENCE_RESET_MS)) {
resetProgress(false);
lastSoundTime = null;
}
}
animationFrameId = requestAnimationFrame(detectPitch);
}
function resetHold() {
holdingCorrectNote = false;
holdStartTime = null;
holdBar.style.width = '0%';
holdBarContainer.classList.remove('visible');
}
function onNoteConfirmed() {
resetHold();
lastNoteConfirmedTime = performance.now();
wrongNoteStartTime = null;
wrongNoteName = null;
setNoteColor(currentNoteIndex, NOTE_COLOR_CORRECT, 'note-glow-green');
currentNoteIndex++;
if (currentNoteIndex >= TARGET_SEQUENCE.length) {
cancelAnimationFrame(animationFrameId);
setTimeout(showSuccess, 400);
} else {
updateProgress();
}
}
function showSuccess() {
listeningArea.style.display = 'none';
successArea.style.display = 'block';
clueText.textContent = CLUE_TEXT;
spawnSparkles();
}
function spawnSparkles() {
const colors = ['#7ee87e', '#c9a0dc', '#f0e6d6', '#ffd700', '#87ceeb'];
let count = 0;
const interval = setInterval(() => {
for (let i = 0; i < 3; i++) {
const sparkle = document.createElement('div');
sparkle.className = 'sparkle';
sparkle.style.left = (10 + Math.random() * 80) + 'vw';
sparkle.style.top = (60 + Math.random() * 30) + 'vh';
sparkle.style.background = colors[Math.floor(Math.random() * colors.length)];
sparkle.style.animation = `sparkle-rise ${1.5 + Math.random()}s ease-out forwards`;
sparkle.style.width = (4 + Math.random() * 4) + 'px';
sparkle.style.height = sparkle.style.width;
document.body.appendChild(sparkle);
sparkle.addEventListener('animationend', () => sparkle.remove());
}
count++;
if (count > 30) clearInterval(interval);
}, 150);
}
// Pre-stage microphone permission on page load so the prompt appears
// before Lily interacts with the puzzle. The audio pipeline is ready
// by the time she clicks "Begin Listening".
let micStream = null;
(async function requestMicEarly() {
try {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
// Permission denied or unavailable — we'll retry on button click
console.warn('Early mic request failed:', err);
}
})();
beginBtn.addEventListener('click', async () => {
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 44100
});
// Use pre-staged stream if available, otherwise request now
if (!micStream) {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
}
const source = audioContext.createMediaStreamSource(micStream);
analyser = audioContext.createAnalyser();
analyser.fftSize = 4096;
source.connect(analyser);
landing.style.display = 'none';
listeningArea.style.display = 'block';
buildStaff();
updateProgress();
detectPitch();
} catch (err) {
console.error('Microphone access error:', err);
beginBtn.textContent = 'Microphone needed \u2014 tap to retry';
}
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment