Created
April 5, 2026 04:45
-
-
Save brasic/ca56303ce3510ca49ecffdb7a57805b1 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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…</h1> | |
| <p class="landing-instructions"> | |
| Play the melody on the card — 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">🎧</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"> </p> | |
| <p class="wrong-message" id="wrongMessage">Wrong note! Starting over…</p> | |
| </div> | |
| </div> | |
| <!-- Success State --> | |
| <div class="success-area" id="success"> | |
| <div class="lock-icon">🔓</div> | |
| <div class="success-title">✨ Melody unlocked! ✨</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