BlueNote_Guitar_Tuner / index.html
QuantumHawk42's picture
Update index.html
b4806f9 verified
Raw History Blame Contribute Delete
13.3 kB
<!DOCTYPE html>
<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) !important; }
.red { color: var(--red) !important; }
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>