<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&nbsp;Hz to 20&nbsp;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>