Download index.html from QuantumHawk42/BlueNote_Guitar_Tuner: direct link, hf CLI and curl.
- Browser
- Download file 13.3 kB
-
https://huggingface.co/spaces/QuantumHawk42/BlueNote_Guitar_Tuner/resolve/main/index.html
- Command line
-
hf download hf://spaces/QuantumHawk42/BlueNote_Guitar_Tuner/index.html
-
curl -L -o index.html https://huggingface.co/spaces/QuantumHawk42/BlueNote_Guitar_Tuner/resolve/main/index.html
13.3 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>BlueNote Guitar Tuner</title> | |
| <style> | |
| :root { | |
| --bg: #08111f; | |
| --panel: #101f34; | |
| --blue: #4ca8ff; | |
| --green: #49e5a4; | |
| --red: #ff647c; | |
| --text: #edf5ff; | |
| --muted: #91a7c2; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; min-height: 100vh; padding: 24px 16px; | |
| background: radial-gradient(ellipse at top, #16335a, var(--bg) 65%); | |
| color: var(--text); | |
| font-family: system-ui, -apple-system, sans-serif; | |
| display: flex; justify-content: center; align-items: center; | |
| } | |
| main { width: 100%; max-width: 520px; } | |
| header { text-align: center; margin-bottom: 24px; } | |
| .logo { font-size: 14px; color: var(--blue); letter-spacing: 4px; } | |
| h1 { margin: 8px 0; font-size: 32px; } | |
| .subtitle { color: var(--muted); font-size: 14px; } | |
| .card { | |
| background: rgba(16,31,52,.94); | |
| border: 1px solid #29415f; | |
| border-radius: 22px; padding: 22px; margin-bottom: 16px; | |
| box-shadow: 0 16px 45px #0004; | |
| } | |
| .status { | |
| color: var(--muted); font-size: 13px; text-align: center; | |
| margin-bottom: 12px; | |
| } | |
| .note { | |
| font-size: 76px; line-height: 1.15; font-weight: 750; | |
| text-align: center; letter-spacing: -3px; | |
| } | |
| .note span { font-size: 22px; color: var(--muted); letter-spacing: 0; } | |
| .frequency { text-align: center; font-size: 17px; margin: 8px 0 20px; } | |
| .frequency strong { color: var(--blue); } | |
| .meter { | |
| height: 12px; background: #263951; border-radius: 20px; | |
| position: relative; margin: 28px 4px 10px; | |
| } | |
| .meter::before { | |
| content: ""; position: absolute; left: 50%; top: -7px; | |
| width: 3px; height: 26px; background: white; border-radius: 4px; | |
| } | |
| .needle { | |
| position: absolute; left: 50%; top: -5px; | |
| width: 5px; height: 22px; border-radius: 4px; | |
| background: var(--blue); transform: translateX(-50%); | |
| transition: left .09s linear, background .1s; | |
| } | |
| .scale { | |
| display: flex; justify-content: space-between; | |
| color: var(--muted); font-size: 12px; | |
| } | |
| .verdict { | |
| text-align: center; font-size: 18px; font-weight: 700; | |
| margin-top: 18px; color: var(--muted); | |
| } | |
| .strings { | |
| display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; | |
| margin-top: 14px; | |
| } | |
| .string { | |
| background: #172a43; color: var(--text); | |
| border: 1px solid #2a4360; border-radius: 12px; | |
| padding: 12px 2px; cursor: pointer; font-size: 19px; | |
| font-weight: 700; | |
| } | |
| .string small { | |
| display: block; font-size: 10px; color: var(--muted); | |
| font-weight: 500; margin-top: 4px; | |
| } | |
| .string.active { | |
| background: #173b60; border-color: var(--blue); | |
| box-shadow: 0 0 14px #4ca8ff22; | |
| } | |
| button { font-family: inherit; } | |
| .start { | |
| width: 100%; padding: 15px; border: 0; border-radius: 14px; | |
| background: var(--blue); color: #071321; font-size: 16px; | |
| font-weight: 800; cursor: pointer; margin-top: 20px; | |
| } | |
| .start:disabled { opacity: .55; cursor: wait; } | |
| .hint { | |
| font-size: 12px; line-height: 1.6; color: var(--muted); | |
| text-align: center; margin-top: 14px; | |
| } | |
| .green { color: var(--green) ; } | |
| .red { color: var(--red) ; } | |
| footer { color: var(--muted); text-align: center; font-size: 11px; } | |
| </style> | |
| </head> | |
| <body> | |
| <main> | |
| <header> | |
| <div class="logo">BLUENOTE • AUDIO TOOLS</div> | |
| <h1>Guitar Tuner</h1> | |
| <div class="subtitle">Precision tuning for your six-string guitar</div> | |
| </header> | |
| <section class="card"> | |
| <div class="status" id="status">MICROPHONE OFF</div> | |
| <div class="note" id="note">—<span id="octave"></span></div> | |
| <div class="frequency"><strong id="frequency">0.00 Hz</strong> | |
| <span style="color:var(--muted)"> · </span> | |
| <span id="target">Target: —</span> | |
| </div> | |
| <div class="meter"> | |
| <div class="needle" id="needle"></div> | |
| </div> | |
| <div class="scale"><span>♭ Flat</span><span>In tune</span><span>Sharp ♯</span></div> | |
| <div class="verdict" id="verdict">Start listening to tune</div> | |
| <button class="start" id="start">🎙 Start Tuning</button> | |
| <div class="hint" id="hint"> | |
| Allow microphone access, then pluck one string at a time. | |
| </div> | |
| </section> | |
| <section class="card"> | |
| <strong>Standard tuning</strong> | |
| <div class="subtitle" style="margin-top:5px"> | |
| Select a string to see its target note and frequency. | |
| </div> | |
| <div class="strings" id="strings"> | |
| <button class="string" data-index="0">E<small>6 · 82 Hz</small></button> | |
| <button class="string" data-index="1">A<small>5 · 110 Hz</small></button> | |
| <button class="string" data-index="2">D<small>4 · 147 Hz</small></button> | |
| <button class="string" data-index="3">G<small>3 · 196 Hz</small></button> | |
| <button class="string" data-index="4">B<small>2 · 247 Hz</small></button> | |
| <button class="string" data-index="5">E<small>1 · 330 Hz</small></button> | |
| </div> | |
| <div class="hint">String 6 is the thickest. String 1 is the thinnest.</div> | |
| </section> | |
| <footer>Standard E–A–D–G–B–E · Equal temperament · A4 = 440 Hz</footer> | |
| </main> | |
| <script> | |
| (() => { | |
| const strings = [ | |
| {name:"E", octave:2, freq:82.4069}, | |
| {name:"A", octave:2, freq:110.0000}, | |
| {name:"D", octave:3, freq:146.8324}, | |
| {name:"G", octave:3, freq:195.9977}, | |
| {name:"B", octave:3, freq:246.9417}, | |
| {name:"E", octave:4, freq:329.6276} | |
| ]; | |
| const $ = id => document.getElementById(id); | |
| const noteEl = $("note"); | |
| const octaveEl = $("octave"); | |
| const frequencyEl = $("frequency"); | |
| const targetEl = $("target"); | |
| const verdictEl = $("verdict"); | |
| const statusEl = $("status"); | |
| const needleEl = $("needle"); | |
| const startBtn = $("start"); | |
| const hintEl = $("hint"); | |
| let audioContext, analyser, stream, buffer; | |
| let running = false; | |
| let selected = -1; // -1 means automatic string detection | |
| let animationId; | |
| const noteNames = ["C","C♯","D","D♯","E","F", | |
| "F♯","G","G♯","A","A♯","B"]; | |
| function resetDisplay(message = "Pluck a string") { | |
| noteEl.firstChild.textContent = "—"; | |
| octaveEl.textContent = ""; | |
| frequencyEl.textContent = "0.00 Hz"; | |
| targetEl.textContent = "Target: —"; | |
| needleEl.style.left = "50%"; | |
| needleEl.style.background = "var(--blue)"; | |
| verdictEl.textContent = message; | |
| verdictEl.style.color = "var(--muted)"; | |
| } | |
| function updateSelection() { | |
| document.querySelectorAll(".string").forEach((button, i) => { | |
| button.classList.toggle("active", i === selected); | |
| }); | |
| if (selected >= 0) { | |
| targetEl.textContent = | |
| `Target: ${strings[selected].name}${strings[selected].octave} · ` + | |
| `${strings[selected].freq.toFixed(2)} Hz`; | |
| } else { | |
| targetEl.textContent = "Target: Auto"; | |
| } | |
| } | |
| document.querySelectorAll(".string").forEach(button => { | |
| button.addEventListener("click", () => { | |
| selected = Number(button.dataset.index); | |
| updateSelection(); | |
| resetDisplay(running ? "Pluck the selected string" : "Ready to tune"); | |
| }); | |
| }); | |
| // YIN-style difference function for pitch detection. | |
| function detectPitch(data, sampleRate) { | |
| const size = data.length; | |
| let rms = 0; | |
| for (let i = 0; i < size; i++) rms += data[i] * data[i]; | |
| rms = Math.sqrt(rms / size); | |
| if (rms < 0.008) return null; | |
| const minLag = Math.floor(sampleRate / 450); | |
| const maxLag = Math.min( | |
| Math.floor(sampleRate / 65), Math.floor(size / 2) | |
| ); | |
| const difference = new Float32Array(maxLag + 1); | |
| const cmnd = new Float32Array(maxLag + 1); | |
| difference[0] = 1; | |
| let runningSum = 0; | |
| for (let lag = 1; lag <= maxLag; lag++) { | |
| let sum = 0; | |
| for (let i = 0; i < size - maxLag; i++) { | |
| const delta = data[i] - data[i + lag]; | |
| sum += delta * delta; | |
| } | |
| difference[lag] = sum; | |
| runningSum += sum; | |
| cmnd[lag] = runningSum > 0 ? sum * lag / runningSum : 1; | |
| } | |
| let tau = -1; | |
| for (let lag = minLag; lag < maxLag; lag++) { | |
| if (cmnd[lag] < 0.18) { | |
| while (lag + 1 < maxLag && cmnd[lag + 1] < cmnd[lag]) lag++; | |
| tau = lag; | |
| break; | |
| } | |
| } | |
| if (tau < 0) { | |
| let best = 1; | |
| for (let lag = minLag; lag <= maxLag; lag++) { | |
| if (cmnd[lag] < best) { | |
| best = cmnd[lag]; | |
| tau = lag; | |
| } | |
| } | |
| if (tau < 0 || best > 0.35) return null; | |
| } | |
| // Parabolic interpolation improves frequency precision. | |
| const a = cmnd[tau - 1] ?? cmnd[tau]; | |
| const b = cmnd[tau]; | |
| const c = cmnd[tau + 1] ?? cmnd[tau]; | |
| const denominator = a - 2 * b + c; | |
| const correction = Math.abs(denominator) > 1e-9 | |
| ? 0.5 * (a - c) / denominator : 0; | |
| const refinedLag = tau + correction; | |
| return refinedLag > 0 ? sampleRate / refinedLag : null; | |
| } | |
| function nearestNote(freq) { | |
| const midi = 69 + 12 * Math.log2(freq / 440); | |
| const rounded = Math.round(midi); | |
| const targetFreq = 440 * Math.pow(2, (rounded - 69) / 12); | |
| return { | |
| name: noteNames[(rounded % 12 + 12) % 12], | |
| octave: Math.floor(rounded / 12) - 1, | |
| targetFreq, | |
| cents: 1200 * Math.log2(freq / targetFreq) | |
| }; | |
| } | |
| function renderPitch(freq) { | |
| const note = nearestNote(freq); | |
| let cents = note.cents; | |
| let target = note.targetFreq; | |
| let label = `${note.name}${note.octave}`; | |
| // If a string is selected, compare against its exact target. | |
| if (selected >= 0) { | |
| const s = strings[selected]; | |
| target = s.freq; | |
| cents = 1200 * Math.log2(freq / target); | |
| label = `${note.name}${note.octave}`; | |
| targetEl.textContent = | |
| `Target: ${s.name}${s.octave} · ${target.toFixed(2)} Hz`; | |
| } else { | |
| targetEl.textContent = `Nearest: ${target.toFixed(2)} Hz`; | |
| } | |
| noteEl.firstChild.textContent = note.name; | |
| octaveEl.textContent = note.octave; | |
| frequencyEl.textContent = `${freq.toFixed(2)} Hz`; | |
| // Limit the needle to the displayed +/- 50 cent range. | |
| const clamped = Math.max(-50, Math.min(50, cents)); | |
| needleEl.style.left = `${50 + clamped}%`; | |
| if (Math.abs(cents) <= 5) { | |
| verdictEl.textContent = "✓ IN TUNE"; | |
| verdictEl.style.color = "var(--green)"; | |
| needleEl.style.background = "var(--green)"; | |
| } else if (cents < 0) { | |
| verdictEl.textContent = `♭ TOO LOW · ${Math.abs(cents).toFixed(1)} cents`; | |
| verdictEl.style.color = "var(--blue)"; | |
| needleEl.style.background = "var(--blue)"; | |
| } else { | |
| verdictEl.textContent = `♯ TOO HIGH · ${cents.toFixed(1)} cents`; | |
| verdictEl.style.color = "var(--red)"; | |
| needleEl.style.background = "var(--red)"; | |
| } | |
| } | |
| function loop() { | |
| if (!running) return; | |
| analyser.getFloatTimeDomainData(buffer); | |
| const pitch = detectPitch(buffer, audioContext.sampleRate); | |
| if (pitch && pitch >= 65 && pitch <= 450) { | |
| statusEl.textContent = "LISTENING · SIGNAL DETECTED"; | |
| renderPitch(pitch); | |
| } else { | |
| statusEl.textContent = "LISTENING · PLUCK A STRING"; | |
| resetDisplay("Waiting for a clear note"); | |
| updateSelection(); | |
| } | |
| animationId = requestAnimationFrame(loop); | |
| } | |
| async function stop() { | |
| running = false; | |
| cancelAnimationFrame(animationId); | |
| if (stream) stream.getTracks().forEach(track => track.stop()); | |
| if (audioContext && audioContext.state !== "closed") { | |
| await audioContext.close(); | |
| } | |
| audioContext = null; | |
| stream = null; | |
| startBtn.textContent = "🎙 Start Tuning"; | |
| statusEl.textContent = "MICROPHONE OFF"; | |
| resetDisplay("Start listening to tune"); | |
| updateSelection(); | |
| } | |
| startBtn.addEventListener("click", async () => { | |
| if (running) { | |
| await stop(); | |
| return; | |
| } | |
| if (!navigator.mediaDevices?.getUserMedia) { | |
| hintEl.textContent = | |
| "Microphone access requires HTTPS or localhost in a supported browser."; | |
| return; | |
| } | |
| startBtn.disabled = true; | |
| hintEl.textContent = "Requesting microphone permission…"; | |
| try { | |
| stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { | |
| echoCancellation: false, | |
| noiseSuppression: false, | |
| autoGainControl: false | |
| } | |
| }); | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| await audioContext.resume(); | |
| const source = audioContext.createMediaStreamSource(stream); | |
| analyser = audioContext.createAnalyser(); | |
| analyser.fftSize = 8192; | |
| analyser.smoothingTimeConstant = 0; | |
| source.connect(analyser); | |
| buffer = new Float32Array(analyser.fftSize); | |
| running = true; | |
| startBtn.textContent = "■ Stop Tuning"; | |
| statusEl.textContent = "LISTENING"; | |
| hintEl.textContent = | |
| "Pluck one string at a time. Tune in a quiet room for best results."; | |
| loop(); | |
| } catch (error) { | |
| if (stream) stream.getTracks().forEach(track => track.stop()); | |
| stream = null; | |
| hintEl.textContent = | |
| "Microphone unavailable. Allow access in your browser settings and try again."; | |
| statusEl.textContent = "MICROPHONE UNAVAILABLE"; | |
| } finally { | |
| startBtn.disabled = false; | |
| } | |
| }); | |
| updateSelection(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |