

—
0 cents
EARS ONLY
Key Transposition
Key of C
Key of Bb
Key of Eb
Key of F
Drone Tone
Rich (Triangle)
Bright (Saw)
Pure (Sine)
let audioCtx, analyser, droneOsc, droneGain, micStream;
let isRunning = false, smoothedCents = 0, strobeAngle = 0, showStrobe = true, animationId, noiseGate = 0.015;
let streakStartTime = null;
let streakActive = false;
const STREAK_THRESHOLD_TIME = 1000;
const STREAK_TOTAL_TIME = 6000;
let difficulty = { smoothing: 0.96, greenZone: 10, yellowZone: 20, strobeSpeed: 0.005, streakRange: 5 };
const notes = [“C”, “C#”, “D”, “D#”, “E”, “F”, “F#”, “G”, “G#”, “A”, “A#”, “B”];
const canvas = document.getElementById(‘tunerCanvas’), ctx = canvas.getContext(‘2d’), toggleBtn = document.getElementById(‘toggleSystemBtn’);
const streakContainer = document.getElementById(‘streak-container’), streakFill = document.getElementById(‘streak-fill’);
function toggleModal() {
const modal = document.getElementById(‘settingsModal’);
modal.style.display = modal.style.display === ‘flex’ ? ‘none’ : ‘flex’;
document.getElementById(‘run-warning’).style.display = isRunning ? ‘none’ : ‘block’;
}
function playTestTone() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const osc = audioCtx.createOscillator(), gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination);
osc.frequency.value = 440; gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5);
osc.start(); osc.stop(audioCtx.currentTime + 0.5);
}
document.getElementById(‘gateSlider’).oninput = function() { noiseGate = parseFloat(this.value); }
function setDifficulty(level) {
document.querySelectorAll(‘.diff-btn’).forEach(b => b.classList.remove(‘selected’));
event.target.classList.add(‘selected’);
if (level === ‘student’) difficulty = { smoothing: 0.96, greenZone: 10, yellowZone: 20, strobeSpeed: 0.005, streakRange: 5 };
else if (level === ‘advanced’) difficulty = { smoothing: 0.90, greenZone: 6, yellowZone: 12, strobeSpeed: 0.015, streakRange: 3 };
else difficulty = { smoothing: 0.80, greenZone: 3, yellowZone: 7, strobeSpeed: 0.04, streakRange: 1 };
resetStreak();
}
async function startSystem() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
try {
micStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false }});
const source = audioCtx.createMediaStreamSource(micStream);
analyser = audioCtx.createAnalyser(); analyser.fftSize = 4096; source.connect(analyser);
droneOsc = audioCtx.createOscillator(); droneGain = audioCtx.createGain();
droneGain.gain.setValueAtTime(0, audioCtx.currentTime);
droneOsc.connect(droneGain); droneGain.connect(audioCtx.destination);
droneOsc.start(); isRunning = true; render();
toggleBtn.innerText = “STOP SYSTEM”; toggleBtn.className = “btn-stop”;
} catch (e) { alert(“Mic Access Required.”); }
}
function stopSystem() {
isRunning = false;
if (droneGain) droneGain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.05);
if (micStream) micStream.getTracks().forEach(track => track.stop());
cancelAnimationFrame(animationId);
resetStreak();
ctx.clearRect(0, 0, 300, 300);
document.getElementById(‘note-name’).innerText = “–“;
document.getElementById(‘note-name’).style.color = “#333”;
document.getElementById(‘cents-display’).innerText = “0 cents”;
toggleBtn.innerText = “START SYSTEM”; toggleBtn.className = “btn-start”;
}
toggleBtn.onclick = () => isRunning ? stopSystem() : startSystem();
function resetStreak() {
streakStartTime = null;
streakActive = false;
streakContainer.classList.remove(‘visible’);
streakFill.style.width = “0%”;
particles = [];
}
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);
document.getElementById('micMeter').style.width = Math.min(rms * 1000, 100) + "%";
if (rms < noiseGate) return -1;
let r1=0, r2=buf.length-1, thres=0.2;
for (let i=0; i<buf.length/2; i++) if (Math.abs(buf[i])<thres) { r1=i; break; }
for (let i=1; i<buf.length/2; i++) if (Math.abs(buf[buf.length-i])<thres) { r2=buf.length-i; break; }
buf = buf.slice(r1,r2);
let c = new Float32Array(buf.length).fill(0);
for (let i=0; i<buf.length; i++) for (let j=0; jc[d+1]) d++;
let maxval=-1, maxpos=-1;
for (let i=d; i maxval) { maxval = c[i]; maxpos = i; }
return sampleRate/maxpos;
}
function render() {
if (!isRunning) return;
const buffer = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(buffer);
const freq = autoCorrelate(buffer, audioCtx.sampleRate);
ctx.clearRect(0, 0, 300, 300);
if (freq !== -1 && freq > 50 && freq 0 ? “+” : “”) + Math.round(smoothedCents) + ” cents”;
if (Math.abs(smoothedCents) = STREAK_THRESHOLD_TIME) {
streakContainer.classList.add(‘visible’);
let progress = ((elapsed – STREAK_THRESHOLD_TIME) / (STREAK_TOTAL_TIME – STREAK_THRESHOLD_TIME)) * 100;
streakFill.style.width = Math.min(progress, 100) + “%”;
if (progress >= 100) {
streakActive = true;
addFlowConfetti();
}
}
} else {
resetStreak();
}
drawVisuals(smoothedCents);
} else {
droneGain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.2);
resetStreak();
}
updateConfetti();
animationId = requestAnimationFrame(render);
}
function drawVisuals(cents) {
const cx = 150, cy = 150, absCents = Math.abs(cents);
let color = “#ff4444”;
if (absCents <= difficulty.greenZone) color = "#00ff00";
else if (absCents <= difficulty.yellowZone) color = "#ffcc00";
document.getElementById('note-name').style.color = (absCents < difficulty.yellowZone + 10) ? color : "#333";
ctx.strokeStyle = color; ctx.lineWidth = 6; ctx.lineCap = "round"; ctx.beginPath();
const rad = (cents * 1.5 – 90) * (Math.PI / 180);
ctx.moveTo(cx, cy); ctx.lineTo(cx + 120 * Math.cos(rad), cy + 120 * Math.sin(rad)); ctx.stroke();
if (showStrobe) {
let strobeMovement = absCents 1 || color === “#00ff00”) {
ctx.strokeStyle = color; ctx.lineWidth = 12; ctx.setLineDash([8, 25]);
ctx.beginPath(); ctx.arc(0, 0, 140, 0, Math.PI * 2); ctx.stroke();
}
ctx.restore();
}
}
const confettiCanvas = document.getElementById(‘confetti-canvas’);
const confCtx = confettiCanvas.getContext(‘2d’);
let particles = [];
function addFlowConfetti() {
confettiCanvas.width = window.innerWidth;
confettiCanvas.height = window.innerHeight;
for (let i = 0; i = 0; i–) {
let p = particles[i];
p.x += p.dirX; p.y += p.dirY; p.rot += p.rotV;
confCtx.save();
confCtx.translate(p.x, p.y);
confCtx.rotate(p.rot);
confCtx.fillStyle = p.color;
confCtx.fillRect(-p.size/2, -p.size/2, p.size, p.size);
confCtx.restore();
if (p.y > window.innerHeight) particles.splice(i, 1);
}
}
document.getElementById(‘earsToggle’).onclick = function() {
const isActive = document.getElementById(‘ears-mask’).classList.toggle(‘active’);
this.innerText = isActive ? “Ears Only: ON” : “Ears Only: OFF”;
document.getElementById(‘cents-display’).classList.toggle(‘hidden’, isActive);
this.classList.toggle(‘active’);
};
document.getElementById(‘strobeToggle’).onclick = function() {
showStrobe = !showStrobe;
this.innerText = showStrobe ? “Strobe: ON” : “Strobe: OFF”;
this.classList.toggle(‘active’);
};