<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>31‑Band Ear Trainer</title>
<style>
:root{
--bg:#0f1115;
--panel:#141824;
--accent:#6ee7ff;
--text:#dbe1f3;
--muted:#6b7280;
--track:#1f2433;
--thumb:#e5e7eb;
--grid:#22283a;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:radial-gradient(1200px 800px at 30% -10%, #1a2030 0%, #0c0f17 60%, #0a0c12 100%);
color:var(--text);
font:15px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
display:flex; align-items:center; justify-content:center;
}
.app{
width:min(1200px, 96vw);
padding:20px 22px 26px;
background:linear-gradient(180deg,#161b27,#0e121b 70%);
border:1px solid #1e2231; border-radius:14px;
box-shadow:0 10px 30px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.03);
}
header{
display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:14px;
}
.title{
display:flex; align-items:baseline; gap:10px;
}
h1{
margin:0; font-size:18px; letter-spacing:.3px; font-weight:700; color:#eef2ff;
}
.sub{ color:var(--muted); font-size:13px }
.controls{
display:flex; align-items:center; gap:18px; flex-wrap:wrap;
}
.control{
background:var(--panel); border:1px solid #22283a; border-radius:10px;
padding:10px 12px; display:flex; align-items:center; gap:10px;
box-shadow:inset 0 1px 0 rgba(255,255,255,.04);
}
.control label{ font-size:12px; color:#aab2c8; letter-spacing:.2px }
.meter{
height:8px; width:140px; background:var(--track); border-radius:999px; position:relative; overflow:hidden;
}
.meter > i{
position:absolute; inset:0; width:40%; background:linear-gradient(90deg,#98f5ff,#4fc3ff);
transition:width .15s ease;
}
.grid{
display:grid;
grid-template-columns: repeat(31, 1fr);
gap:14px;
padding:18px 12px 6px;
border-radius:12px;
background:
linear-gradient(180deg, transparent 0 50%, rgba(255,255,255,.02) 50% 100%),
linear-gradient(transparent, transparent) padding-box;
border:1px solid #1e2231;
}
.band{
display:flex; flex-direction:column; align-items:center; gap:10px;
}
/* Vertical range slider */
input[type="range"].fader{
-webkit-appearance:none; appearance:none;
writing-mode: bt-lr; /* IE */
width:42px; height:220px;
background:transparent; outline:none;
touch-action:none;
}
/* Track */
input[type="range"].fader::-webkit-slider-runnable-track{
width:6px; height:220px; background:var(--track);
border-radius:999px; border:1px solid #2a3145;
margin:0 auto;
}
input[type="range"].fader::-moz-range-track{
width:6px; height:220px; background:var(--track);
border-radius:999px; border:1px solid #2a3145;
}
/* Thumb */
input[type="range"].fader::-webkit-slider-thumb{
-webkit-appearance:none; appearance:none;
width:28px; height:12px; border-radius:6px;
background:var(--thumb); border:1px solid #d1d5db;
margin-top:-4px; /* centers on track */
box-shadow: 0 2px 3px rgba(0,0,0,.25);
}
input[type="range"].fader::-moz-range-thumb{
width:28px; height:12px; border-radius:6px;
background:var(--thumb); border:1px solid #d1d5db;
box-shadow: 0 2px 3px rgba(0,0,0,.25);
}
.hz{
font-variant-numeric: tabular-nums;
font-size:12px; color:#9fb3d6; letter-spacing:.2px; user-select:none;
}
.hz strong{ color:#dfe7ff; font-weight:600 }
.hint{
margin-top:10px; color:#9aa3b8; font-size:12px; text-align:center;
opacity:.9;
}
.active-ind{
width:8px; height:8px; border-radius:50%;
background:#2a3349; box-shadow:inset 0 0 0 1px #3a4260;
margin-top:2px;
}
.band.playing .active-ind{
background:radial-gradient(circle at 30% 30%, #b9f7ff 0 35%, #6ee7ff 50%, #0cf 100%);
box-shadow:0 0 10px rgba(111,231,255,.6), inset 0 0 0 1px rgba(0,0,0,.25);
}
footer{
margin-top:16px; display:flex; justify-content:space-between; align-items:center; gap:12px;
color:#8190ad; font-size:12px;
}
.kbd{
font:600 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
background:#111522; border:1px solid #2a3145; padding:2px 6px; border-radius:6px; color:#cbd5e1;
}
@media (max-width: 900px){
input[type="range"].fader{ height:180px }
.grid{ gap:10px }
.meter{ width:110px }
}
</style>
</head>
<body>
<div class="app" id="app">
<header>
<div class="title">
<h1>31‑Band Ear Trainer</h1>
<span class="sub">Hold a fader to play its center frequency</span>
</div>
<div class="controls">
<div class="control" title="Set output level">
<label for="master">Master</label>
<input id="master" type="range" min="0" max="100" value="35" />
<div class="meter" aria-hidden="true"><i style="width:35%"></i></div>
</div>
<div class="control" title="Tone shape">
<label for="wavesel">Wave</label>
<select id="wavesel">
<option value="sine" selected>Sine</option>
<option value="triangle">Triangle</option>
<option value="square">Square</option>
<option value="sawtooth">Saw</option>
</select>
</div>
<div class="control" title="Fade times to prevent clicks">
<label>Ramp</label>
<span class="sub">2 ms</span>
</div>
</div>
</header>
<div class="grid" id="grid" role="group" aria-label="31-band graphic EQ">
<!-- Bands injected by JS -->
</div>
<div class="hint">
Tip: you can use keyboard too — focus a fader and hold <span class="kbd">Space</span> or <span class="kbd">Enter</span> to play; release to stop.
</div>
<footer>
<div>ISO 1/3‑octave centers from 20 Hz to 20 kHz.</div>
<div id="status" aria-live="polite"></div>
</footer>
</div>
<script>
(() => {
// ISO 1/3 octave centers for a 31-band GEQ
const FREQUENCIES = [
20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250,
315, 400, 500, 630, 800, 1000, 1250, 1600, 2000, 2500,
3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000
];
// Audio setup
const audio = {
ctx: null,
master: null,
current: { osc: null, gain: null, bandEl: null, freq: null },
ramp: 0.002 // seconds
};
function ensureContext() {
if (!audio.ctx) {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const master = ctx.createGain();
master.gain.value = Number(masterSlider.value) / 100;
master.connect(ctx.destination);
audio.ctx = ctx;
audio.master = master;
}
if (audio.ctx.state === 'suspended') {
audio.ctx.resume();
}
}
// UI elements
const grid = document.getElementById('grid');
const status = document.getElementById('status');
const masterSlider = document.getElementById('master');
const meterFill = document.querySelector('.meter > i');
const waveSel = document.getElementById('wavesel');
// Build bands
FREQUENCIES.forEach((hz, idx) => {
const band = document.createElement('div');
band.className = 'band';
band.dataset.freq = hz;
band.setAttribute('role','group');
band.setAttribute('aria-label', `${hz} Hz band`);
const fader = document.createElement('input');
fader.type = 'range';
fader.min = "-12";
fader.max = "12";
fader.value = "0";
fader.className = 'fader';
fader.setAttribute('aria-label', `${hz} Hz fader (hold to play)`);
const dot = document.createElement('div');
dot.className = 'active-ind';
const label = document.createElement('div');
label.className = 'hz';
label.innerHTML = formatHz(hz);
band.appendChild(fader);
band.appendChild(dot);
band.appendChild(label);
grid.appendChild(band);
// Pointer interactions (mouse/touch/pen): hold to play
const start = (e) => {
e.preventDefault();
playBand(band, hz);
};
const stop = () => stopBand(band);
fader.addEventListener('pointerdown', start);
// End conditions: pointer up anywhere, leaving viewport, or cancel
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
fader.addEventListener('pointerleave', (e) => {
if (e.pressure === 0) stop();
});
// Keyboard: Space or Enter to play while key held on focused fader
fader.addEventListener('keydown', (e) => {
if ((e.code === 'Space' || e.code === 'Enter') && !band.classList.contains('playing')) {
e.preventDefault();
playBand(band, hz);
}
});
fader.addEventListener('keyup', (e) => {
if (e.code === 'Space' || e.code === 'Enter') {
e.preventDefault();
stopBand(band);
}
});
// Also stop if element blurs while playing
fader.addEventListener('blur', () => stopBand(band));
});
function formatHz(hz){
if (hz >= 1000) {
if (hz % 1000 === 0) return `<strong>${hz/1000}</strong> k`;
// 3.15k style
return `<strong>${(hz/1000).toLocaleString(undefined,{maximumFractionDigits:2})}</strong> k`;
}
if (hz === 31.5) return `<strong>31.5</strong> Hz`;
return `<strong>${hz}</strong> Hz`;
}
function playBand(band, freq){
ensureContext();
// If another tone is playing, stop it first
if (audio.current.osc) stopBand(audio.current.bandEl, /*fast*/true);
const osc = audio.ctx.createOscillator();
const g = audio.ctx.createGain();
try { osc.type = waveSel.value || 'sine'; } catch { osc.type = 'sine'; }
osc.frequency.setValueAtTime(freq, audio.ctx.currentTime);
// Soft start to avoid clicks
g.gain.setValueAtTime(0, audio.ctx.currentTime);
g.gain.linearRampToValueAtTime(1, audio.ctx.currentTime + audio.ramp);
osc.connect(g).connect(audio.master);
osc.start();
band.classList.add('playing');
audio.current = { osc, gain: g, bandEl: band, freq };
status.textContent = `${freq} Hz ${osc.type}`;
}
function stopBand(band, fast=false){
if (!audio.current.osc || audio.current.bandEl !== band) return;
const { osc, gain } = audio.current;
const tNow = audio.ctx.currentTime;
const ramp = fast ? audio.ramp * 0.5 : audio.ramp;
try {
gain.gain.cancelScheduledValues(tNow);
gain.gain.setValueAtTime(gain.gain.value, tNow);
gain.gain.linearRampToValueAtTime(0, tNow + ramp);
} catch {}
// Stop osc slightly after ramp
osc.stop(tNow + ramp + 0.005);
// Cleanup
setTimeout(() => {
try { osc.disconnect(); gain.disconnect(); } catch {}
}, (ramp + 0.02)*1000);
band.classList.remove('playing');
audio.current = { osc: null, gain: null, bandEl: null, freq: null };
status.textContent = '';
}
// Master volume
masterSlider.addEventListener('input', (e) => {
ensureContext();
const v = Number(e.target.value) / 100;
meterFill.style.width = `${e.target.value}%`;
const t = audio.ctx.currentTime;
audio.master.gain.cancelScheduledValues(t);
audio.master.gain.setValueAtTime(audio.master.gain.value, t);
audio.master.gain.linearRampToValueAtTime(v, t + 0.02);
});
// Resume audio context on user gesture (iOS/Safari)
['click','touchstart','keydown','pointerdown'].forEach(ev =>
document.addEventListener(ev, () => {
if (audio.ctx && audio.ctx.state === 'suspended') audio.ctx.resume();
}, { once:true, passive:true })
);
// Prevent page scroll during slider drags on touch devices
grid.addEventListener('touchmove', (e) => { if (e.target.classList.contains('fader')) e.preventDefault(); }, { passive:false });
})();
</script>
</body>
</html>