Chord Explorer
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Pick a root and a chord type and three things happen at once: the exact keys light up on a two-octave piano, a soft two-oscillator Web Audio synth plays the chord, and every note gets a plain-English job description — which one makes it bright, which one makes it restless, which one is just holding the bottom up. The names are spelled out from the intervals rather than looked up in a table, so a diminished chord on D♭ quietly re-spells itself as C♯ instead of handing you a double flat. Underneath is a four-slot progression strip that loops, and every edit lands on the chord that is currently sounding, so you can rebuild the progression while it goes round. Start with Loop all four on the doo-wop pattern, swap chord 4 from 7 to maj7 to hear the ending stop pulling, then step through the inversions and watch the same notes rearrange themselves under your hands.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Chord Explorer — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
/* Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local values the shared tokens don't carry */
:root {
--shadow: 0 1px 2px rgba(26,29,33,.05);
--well: #F1F0EC; /* recessed panel the keys sit in */
--rail: #B0ACA2; /* strip the keys hinge on */
--wkey: #FFFFFF; --wkey-lo: #EFEDE6; --wkey-edge: #D5D3CB;
--bkey: #23262C; --bkey-lo: #14161A;
--keylit: #FFE2BC; --keylit-lo: #F6CE9A; --keylit-ink: #4A2C05;
--keyroot: #C05600; --keyroot-lo: #A44900; --keyroot-ink: #FFFFFF;
--bklit: #E39434; --bklit-lo: #C97E22; --bklit-ink: #2A1806;
--bkroot: #C05600; --bkroot-lo: #8E4000; --bkroot-ink: #FFFFFF;
--wkey-ink: rgba(26,29,33,.58);
--btn-ink: #FFFFFF;
--pianoH: clamp(118px, 29vw, 158px);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--shadow: 0 1px 2px rgba(0,0,0,.35);
--well: #16181D;
--rail: #0C0E11;
--wkey: #DAD7D0; --wkey-lo: #B4B0A8; --wkey-edge: #4A4E56;
--bkey: #23262C; --bkey-lo: #0C0E11;
--keylit: #F0C88C; --keylit-lo: #CFA162; --keylit-ink: #2C1D06;
--keyroot: #FFB454; --keyroot-lo: #DE952F; --keyroot-ink: #14161A;
--bklit: #C08B3E; --bklit-lo: #94682A; --bklit-ink: #14161A;
--bkroot: #FFB454; --bkroot-lo: #C98A34; --bkroot-ink: #14161A;
--wkey-ink: rgba(20,22,26,.62);
--btn-ink: #14161A;
}
}
:root[data-theme="dark"] {
--shadow: 0 1px 2px rgba(0,0,0,.35);
--well: #16181D;
--rail: #0C0E11;
--wkey: #DAD7D0; --wkey-lo: #B4B0A8; --wkey-edge: #4A4E56;
--bkey: #23262C; --bkey-lo: #0C0E11;
--keylit: #F0C88C; --keylit-lo: #CFA162; --keylit-ink: #2C1D06;
--keyroot: #FFB454; --keyroot-lo: #DE952F; --keyroot-ink: #14161A;
--bklit: #C08B3E; --bklit-lo: #94682A; --bklit-ink: #14161A;
--bkroot: #FFB454; --bkroot-lo: #C98A34; --bkroot-ink: #14161A;
--wkey-ink: rgba(20,22,26,.62);
--btn-ink: #14161A;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap {
max-width: 960px; margin: 0 auto;
padding: 22px 16px 26px;
display: flex; flex-direction: column; gap: 13px;
}
@media (max-width: 460px) { .wrap { padding: 16px 9px 20px; gap: 10px; } }
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 14px;
}
@media (max-width: 460px) { .card { padding: 11px 10px; } }
.ct { font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em;
color: var(--muted); margin: 0 0 9px; text-transform: uppercase; }
.ct span { text-transform: none; letter-spacing: 0; font-family: var(--font-sans); }
/* ── stage ────────────────────────────────────────────────────────────────── */
.stage { display: grid; grid-template-columns: 1fr; gap: 12px; align-items: start; }
@media (min-width: 640px) { .stage { grid-template-columns: minmax(0,1fr) auto; } }
.sym { font-family: var(--font-mono); font-size: clamp(31px, 8.2vw, 50px);
line-height: 1.02; letter-spacing: -.03em; font-weight: 600; word-break: break-word; }
.sym .sfx { color: var(--accent); }
.symsub { font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .06em;
color: var(--muted); margin-top: 6px; text-transform: uppercase; }
.mood { margin: 8px 0 0; font-size: 14px; line-height: 1.5; color: var(--ink); max-width: 44ch; }
.acts { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; margin-top: 12px; }
.btn {
font: inherit; font-size: 13.5px; font-weight: 600;
color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px;
padding: 8px 13px; cursor: pointer;
display: inline-flex; align-items: center; gap: 7px;
transition: background .12s ease, border-color .12s ease, transform .06s ease;
-webkit-tap-highlight-color: transparent;
}
.btn:hover { background: var(--accent-soft); border-color: var(--accent); }
.btn:active { transform: translateY(1px); }
.btn.primary {
background: var(--accent); border-color: var(--accent); color: var(--btn-ink);
justify-content: center; padding: 9px 16px; position: relative;
}
.btn.primary:hover { filter: brightness(1.07); background: var(--accent); }
.btn.primary.on { background: var(--surface); color: var(--ink); border-color: var(--ink); }
.btn.primary.nudge::after {
content: ""; position: absolute; inset: -4px; border-radius: 11px;
border: 2px solid var(--accent); opacity: 0;
animation: ring 2.8s ease-out infinite; pointer-events: none;
}
@keyframes ring { 0% { opacity: .5; transform: scale(1) } 45%,100% { opacity: 0; transform: scale(1.13) } }
.btn .gl { font-size: 11px; line-height: 1; }
.status {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .07em;
padding: 5px 9px; border-radius: 99px; white-space: nowrap;
background: var(--well); color: var(--muted); border: 1px solid var(--line);
}
.status .led { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.status.ok { background: var(--run-soft); color: var(--run); border-color: var(--run); }
.status.ok .led { background: var(--run); }
.status.bad { background: var(--surface); color: var(--danger); border-color: var(--danger); }
.status.bad .led { background: var(--danger); }
/* readout */
.ro {
border: 1px solid var(--line); border-radius: 8px; background: var(--well);
padding: 9px 11px; display: grid; gap: 7px 16px;
grid-template-columns: repeat(2, minmax(0,1fr));
}
@media (min-width: 640px) { .ro { grid-template-columns: minmax(0,1fr); min-width: 208px; } }
.rf { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.rk { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .12em; color: var(--muted); }
.rv { font-family: var(--font-mono); font-size: 13.5px; font-weight: 600; color: var(--ink);
overflow-wrap: anywhere; }
/* ── piano ────────────────────────────────────────────────────────────────── */
.pwrap { margin-top: 13px; background: var(--well); border: 1px solid var(--line);
border-radius: 9px; padding: 9px 9px 8px; }
.rails { height: 5px; border-radius: 3px 3px 0 0; background: var(--rail); opacity: .55; }
.piano { position: relative; height: var(--pianoH); margin-top: 0; touch-action: manipulation; }
.piano:focus { outline: none; }
.key { position: absolute; top: 0; cursor: pointer; padding: 0; margin: 0;
font: inherit; border: 1px solid var(--wkey-edge); background: transparent;
display: flex; flex-direction: column; justify-content: flex-end;
align-items: center; gap: 1px; padding-bottom: 5px;
-webkit-tap-highlight-color: transparent; user-select: none;
transition: filter .12s ease; }
.key.w { height: 100%; z-index: 1; border-radius: 0 0 5px 5px;
background: linear-gradient(180deg, var(--wkey) 0%, var(--wkey) 74%, var(--wkey-lo) 100%); }
.key.b { height: 61%; z-index: 2; border-radius: 0 0 4px 4px; border-color: var(--bkey-lo);
background: linear-gradient(180deg, var(--bkey) 0%, var(--bkey-lo) 100%);
box-shadow: 0 2px 3px rgba(0,0,0,.28); }
.key.w.lit { background: linear-gradient(180deg, var(--keylit) 0%, var(--keylit-lo) 100%);
border-color: var(--keyroot); }
.key.w.rootk { background: linear-gradient(180deg, var(--keyroot) 0%, var(--keyroot-lo) 100%);
border-color: var(--keyroot-lo); }
.key.b.lit { background: linear-gradient(180deg, var(--bklit) 0%, var(--bklit-lo) 100%);
border-color: var(--bklit-lo); }
.key.b.rootk { background: linear-gradient(180deg, var(--bkroot) 0%, var(--bkroot-lo) 100%);
border-color: var(--bkroot-lo); }
/* flash must read on a plain WHITE key too, where brightness() is a no-op */
.key.snd { filter: brightness(1.1) saturate(1.05);
box-shadow: inset 0 0 0 2px var(--accent), 0 1px 3px rgba(0,0,0,.22); }
.key.w:hover:not(.lit):not(.rootk) {
background: linear-gradient(180deg, var(--wkey-lo) 0%, var(--wkey-edge) 100%); }
.key.b:hover, .key.lit:hover, .key.rootk:hover { filter: brightness(1.08); }
.kb1 { font-family: var(--font-mono); font-size: clamp(8.5px, 2.1vw, 10.5px);
font-weight: 700; line-height: 1; opacity: 0; }
.kb2 { font-family: var(--font-mono); font-size: clamp(7.5px, 1.9vw, 9.5px);
line-height: 1; opacity: 0; }
.key.lit .kb1, .key.lit .kb2, .key.rootk .kb1, .key.rootk .kb2 { opacity: 1; }
.key.w.lit .kb1, .key.w.lit .kb2 { color: var(--keylit-ink); }
.key.w.rootk .kb1, .key.w.rootk .kb2 { color: var(--keyroot-ink); }
.key.b.lit .kb1, .key.b.lit .kb2 { color: var(--bklit-ink); }
.key.b.rootk .kb1, .key.b.rootk .kb2 { color: var(--bkroot-ink); }
.key.oct .kb2 { opacity: 1; color: var(--wkey-ink); letter-spacing: .04em; }
.key:focus-visible { outline: 2px solid var(--ink); outline-offset: -3px; z-index: 6; }
/* ── chips ────────────────────────────────────────────────────────────────── */
.grp + .grp { margin-top: 12px; }
.gl2 { font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em;
color: var(--muted); display: block; margin-bottom: 6px; }
.gl2 em { font-style: normal; color: var(--ink); }
.chips { display: grid; gap: 6px; }
.chips.roots { grid-template-columns: repeat(auto-fit, minmax(44px, 1fr)); }
.chips.types { grid-template-columns: repeat(auto-fit, minmax(62px, 1fr)); }
.chips.invs { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.chip {
font-family: var(--font-mono); font-size: 12.5px; font-weight: 600;
color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 7px;
padding: 8px 4px; cursor: pointer; text-align: center;
transition: background .12s ease, border-color .12s ease, color .12s ease;
-webkit-tap-highlight-color: transparent; min-width: 0;
}
.chip:hover:not(:disabled) { background: var(--accent-soft); border-color: var(--accent); }
.chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--btn-ink); }
.chip:disabled { opacity: .38; cursor: not-allowed; }
.chip .sub { display: block; font-size: 9px; letter-spacing: .04em; opacity: .72;
font-weight: 400; margin-top: 2px; }
/* ── intervals ────────────────────────────────────────────────────────────── */
.cols { display: grid; grid-template-columns: minmax(0,1fr); gap: 13px; align-items: start; }
@media (min-width: 880px) { .cols { grid-template-columns: minmax(0,1fr) minmax(0,1.05fr); } }
.ivs { list-style: none; margin: 0; padding: 0; }
.iv { display: grid; grid-template-columns: auto minmax(0,1fr) auto; gap: 3px 10px;
align-items: center; padding: 10px 0; border-top: 1px dashed var(--line); }
.iv:first-child { border-top: 0; padding-top: 2px; }
.ivn { font-family: var(--font-mono); font-size: 13px; font-weight: 700;
min-width: 34px; text-align: center; padding: 4px 6px; border-radius: 6px;
background: var(--accent-soft); color: var(--accent); border: 1px solid var(--accent); }
.iv.root .ivn { background: var(--accent); color: var(--btn-ink); }
.ivr { font-size: 14px; font-weight: 600; }
.ivr b { font-weight: 600; }
.ivs2 { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); white-space: nowrap; }
.ivt { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
/* ── progression ──────────────────────────────────────────────────────────── */
.strip { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 6px; }
.slot {
font: inherit; cursor: pointer; text-align: left; min-width: 0;
background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
padding: 8px 8px 7px; display: flex; flex-direction: column; gap: 3px;
transition: background .12s ease, border-color .12s ease;
-webkit-tap-highlight-color: transparent;
}
.slot:hover { border-color: var(--accent); }
.slot .n { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .1em; color: var(--muted); }
.slot .s { font-family: var(--font-mono); font-size: clamp(13px, 3.6vw, 16px);
font-weight: 700; line-height: 1.1; overflow-wrap: anywhere; }
.slot .t { font-size: 10.5px; color: var(--muted); line-height: 1.2; }
.slot[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.slot[aria-pressed="true"] .n { color: var(--accent); }
.slot.playing { border-color: var(--run); box-shadow: inset 0 0 0 2px var(--run); }
.slot.playing .n { color: var(--run); }
.prow { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 11px; }
.pre { font-family: var(--font-mono); font-size: 11.5px; font-weight: 600; color: var(--ink);
background: var(--surface); border: 1px solid var(--line); border-radius: 99px;
padding: 6px 11px; cursor: pointer; transition: background .12s ease, border-color .12s ease;
-webkit-tap-highlight-color: transparent; }
.pre:hover { background: var(--accent-soft); border-color: var(--accent); }
.tempo { display: flex; align-items: center; gap: 9px; flex: 1 1 190px; min-width: 160px; }
.tempo label { font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em; color: var(--muted); }
.tempo output { font-family: var(--font-mono); font-size: 11.5px; font-weight: 600;
white-space: nowrap; min-width: 62px; text-align: right; }
input[type=range] { flex: 1; min-width: 60px; accent-color: var(--accent); height: 22px; }
.btn:focus-visible, .chip:focus-visible, .slot:focus-visible, .pre:focus-visible,
input[type=range]:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* thumb-sized targets on phones (the pills were only 28px tall) */
@media (max-width: 460px) {
.pre { padding: 9px 13px; }
.chip { padding: 10px 4px; }
}
.hint { margin: 0; color: var(--muted); font-size: 12.5px; line-height: 1.6; max-width: 70ch; }
.hint b { color: var(--ink); font-weight: 600; }
kbd { font-family: var(--font-mono); font-size: 11px;
border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px;
padding: 1px 5px; background: var(--surface); color: var(--ink);
margin-right: 1px; white-space: nowrap; }
.note { font-family: var(--font-mono); font-size: 12px; color: var(--danger); margin: 0; }
footer { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
letter-spacing: .04em; padding-top: 2px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden;
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important; animation-iteration-count: 1 !important;
transition-duration: .001ms !important; scroll-behavior: auto !important;
}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Chord Explorer</h1>
<p class="lede">Pick a root note and a chord type. The keys it uses light up, you hear it,
and the three or four notes inside it get explained in <b>plain words</b> — then queue
four chords and loop them.</p>
</header>
<main class="card">
<div class="stage">
<div>
<div class="sym" id="sym">C</div>
<div class="symsub" id="symsub">C major · root position</div>
<p class="mood" id="mood">Bright and settled.</p>
<div class="acts">
<button class="btn primary nudge" id="playBtn" type="button">
<span class="gl" aria-hidden="true">▶</span>Play chord</button>
<button class="btn" id="loopBtn" type="button" aria-pressed="false">
<span class="gl" aria-hidden="true">↻</span><span id="loopLbl">Loop all four</span></button>
<span class="status" id="status"><span class="led" aria-hidden="true"></span><span id="statusTxt">TAP PLAY</span></span>
</div>
</div>
<div class="ro">
<div class="rf"><span class="rk">NOTES</span><span class="rv" id="roNotes">C E G</span></div>
<div class="rf"><span class="rk">STEPS FROM ROOT</span><span class="rv" id="roSteps">0 4 7</span></div>
<div class="rf"><span class="rk">LOWEST NOTE</span><span class="rv" id="roBass">C</span></div>
<div class="rf"><span class="rk">MIDI</span><span class="rv" id="roMidi">60 64 67</span></div>
</div>
</div>
<div class="pwrap">
<div class="rails" aria-hidden="true"></div>
<div class="piano" id="piano" role="group"
aria-label="Piano, C4 to C6. Left and right arrows move between keys, Enter or Space plays one."></div>
</div>
</main>
<section class="card">
<p class="ct">Build a chord</p>
<div class="grp">
<span class="gl2">ROOT — <em>the note it is named after</em></span>
<div class="chips roots" id="rootChips"></div>
</div>
<div class="grp">
<span class="gl2">TYPE — <em>the shape stacked on top</em></span>
<div class="chips types" id="typeChips"></div>
</div>
<div class="grp">
<span class="gl2">INVERSION — <em>which note sits at the bottom</em></span>
<div class="chips invs" id="invChips"></div>
</div>
</section>
<div class="cols">
<section class="card">
<p class="ct">Inside the chord <span id="ivHead"></span></p>
<ul class="ivs" id="ivList"></ul>
</section>
<section class="card">
<p class="ct">Progression — <span>four chords on a loop</span></p>
<div class="strip" id="strip"></div>
<div class="prow" id="presets"></div>
<div class="prow">
<div class="tempo">
<label for="bpm">PACE</label>
<input type="range" id="bpm" min="52" max="152" step="2" value="96"
aria-label="Pace in beats per minute">
<output id="bpmOut" for="bpm">96 BPM</output>
</div>
</div>
</section>
</div>
<p class="hint"><b>Try this:</b> hit <b>Loop all four</b>, then swap chord 4 from <b>7</b> to
<b>maj7</b> and hear the ending soften. Tweaks land on the chord that is playing.
Keys: <kbd>P</kbd> plays the chord, <kbd>L</kbd> starts and stops the loop,
<kbd>1</kbd>–<kbd>4</kbd> pick a slot.</p>
<p class="note" id="audioNote" hidden>This browser has no Web Audio support, so the keys light up but stay silent.</p>
<p class="sr" id="live" role="status" aria-live="polite"></p>
<footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";
var root = document.documentElement;
/* ── theme plumbing ─────────────────────────────────────────────────────── */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme);
}
});
var mqReduce = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
var reduce = !!(mqReduce && mqReduce.matches);
if (mqReduce && mqReduce.addEventListener) {
mqReduce.addEventListener("change", function (e) { reduce = e.matches; });
}
/* ── music theory ───────────────────────────────────────────────────────── */
var LETTERS = ["C", "D", "E", "F", "G", "A", "B"];
var LETTER_PC = [0, 2, 4, 5, 7, 9, 11];
var SIMPLE = ["C", "D♭", "D", "E♭", "E", "F", "F♯", "G", "A♭", "A", "B♭", "B"];
var ROOTS = [
{ name: "C", letter: 0, acc: 0, pc: 0 },
{ name: "D♭", letter: 1, acc: -1, pc: 1 },
{ name: "D", letter: 1, acc: 0, pc: 2 },
{ name: "E♭", letter: 2, acc: -1, pc: 3 },
{ name: "E", letter: 2, acc: 0, pc: 4 },
{ name: "F", letter: 3, acc: 0, pc: 5 },
{ name: "F♯", letter: 3, acc: 1, pc: 6 },
{ name: "G", letter: 4, acc: 0, pc: 7 },
{ name: "A♭", letter: 5, acc: -1, pc: 8 },
{ name: "A", letter: 5, acc: 0, pc: 9 },
{ name: "B♭", letter: 6, acc: -1, pc: 10 },
{ name: "B", letter: 6, acc: 0, pc: 11 }
];
/* enharmonic spare spelling, used when the first one needs double accidentals */
var ENH = {
1: { name: "C♯", letter: 0, acc: 1, pc: 1 },
3: { name: "D♯", letter: 1, acc: 1, pc: 3 },
6: { name: "G♭", letter: 4, acc: -1, pc: 6 },
8: { name: "G♯", letter: 4, acc: 1, pc: 8 },
10: { name: "A♯", letter: 5, acc: 1, pc: 10 }
};
var TYPES = [
{ id: "maj", suffix: "", label: "major", deg: [1,3,5], semis: [0,4,7],
mood: "Bright and settled. This is the plain, everyday “happy” chord." },
{ id: "min", suffix: "m", label: "minor", deg: [1,3,5], semis: [0,3,7],
mood: "Softer and darker. Only one note moved — the middle one dropped a single key." },
{ id: "7", suffix: "7", label: "seventh", deg: [1,3,5,7], semis: [0,4,7,10],
mood: "Restless. It leans forward and makes you expect another chord right after it." },
{ id: "maj7", suffix: "maj7", label: "major seventh", deg: [1,3,5,7], semis: [0,4,7,11],
mood: "Warm and floating. The chord you hear in slow soul and coffee-shop jazz." },
{ id: "min7", suffix: "m7", label: "minor seventh", deg: [1,3,5,7], semis: [0,3,7,10],
mood: "Mellow and smooth — minor, but relaxed about it." },
{ id: "sus4", suffix: "sus4", label: "suspended fourth", deg: [1,4,5], semis: [0,5,7],
mood: "No middle note at all, so it is neither happy nor sad. It just hangs, waiting." },
{ id: "dim", suffix: "dim", label: "diminished", deg: [1,3,5], semis: [0,3,6],
mood: "Tense and unsettled. Every gap inside it is the same size, so it never sounds at home." }
];
var ROLE_SHORT = { 1: "R", 3: "3", 4: "4", 5: "5", 7: "7" };
var ROLE_WORD = { 1: "the root", 3: "the third", 4: "the fourth", 5: "the fifth", 7: "the seventh" };
var IV = {
0: ["the starting note", "The note the chord takes its name from. Everything else is measured up from here."],
3: ["minor third", "Three keys up. Dropping this one note by a single key is the whole difference between happy and sad."],
4: ["major third", "Four keys up. This is the note doing the smiling."],
5: ["fourth", "Five keys up, standing where the third would normally be — which is why the chord sounds like it is waiting for something."],
6: ["flat fifth", "Six keys up, dead centre of the octave. Nothing sits still here."],
7: ["fifth", "Seven keys up. The wide, hollow one that makes the chord feel solid underneath."],
10: ["flat seventh", "Ten keys up. Adds a bluesy rub and tips the chord toward whatever comes next."],
11: ["major seventh", "Eleven keys up — one key short of the octave. Soft and glassy; the jazz shimmer."]
};
var ACC = { "-2": "𝄫", "-1": "♭", "0": "", "1": "♯", "2": "𝄪" };
function spellOne(r, degree, semis) {
var li = (r.letter + (degree - 1)) % 7;
var nat = LETTER_PC[li];
var target = (r.pc + semis) % 12;
var acc = (target - nat + 24) % 12;
if (acc > 6) acc -= 12;
var mark = ACC[String(acc)];
if (mark === undefined) mark = "?";
return { letter: LETTERS[li], acc: acc, name: LETTERS[li] + mark };
}
function spellChord(rIdx, tIdx) {
var t = TYPES[tIdx];
var cands = [ROOTS[rIdx]];
if (ENH[rIdx]) cands.push(ENH[rIdx]);
for (var c = 0; c < cands.length; c++) {
var out = [], fine = true;
for (var i = 0; i < t.deg.length; i++) {
var s = spellOne(cands[c], t.deg[i], t.semis[i]);
if (Math.abs(s.acc) > 1) { fine = false; break; }
out.push(s.name);
}
if (fine) return out;
}
/* last resort: plain enharmonic names, still correct for those pitches */
var plain = [];
for (var k = 0; k < t.semis.length; k++) plain.push(SIMPLE[(ROOTS[rIdx].pc + t.semis[k]) % 12]);
return plain;
}
var BASE = 60; /* root position always starts in the octave from middle C */
function build(slot) {
var t = TYPES[slot.t];
var names = spellChord(slot.r, slot.t);
var n = t.semis.length;
var inv = Math.min(slot.i, n - 1);
var notes = [];
for (var i = 0; i < n; i++) {
notes.push({
name: names[i],
deg: t.deg[i],
semis: t.semis[i],
role: ROLE_SHORT[t.deg[i]],
midi: BASE + ROOTS[slot.r].pc + t.semis[i] + (i < inv ? 12 : 0)
});
}
/* Inverting lifts the low voices an octave, which for high roots walked the
chord clean off the top of the drawn keyboard (B maj7 / 2nd inv reached
MIDI 90 vs. the C6 = 84 top key) — it sounded but nothing lit up. Drop the
whole voicing back so the bass always sits in the octave from middle C;
an inversion spans at most 11 semitones, so the top stays <= 82. */
var lowest = Infinity;
for (i = 0; i < n; i++) if (notes[i].midi < lowest) lowest = notes[i].midi;
var shift = -12 * Math.floor((lowest - BASE) / 12);
if (shift) for (i = 0; i < n; i++) notes[i].midi += shift;
var sorted = notes.slice().sort(function (a, b) { return a.midi - b.midi; });
var sym = names[0] + t.suffix + (inv > 0 ? "/" + names[inv] : "");
return {
type: t, notes: notes, sorted: sorted, inv: inv, voices: n,
rootName: names[0], suffix: t.suffix + (inv > 0 ? "/" + names[inv] : ""),
symbol: sym, bass: names[inv]
};
}
function freq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
/* ── audio ──────────────────────────────────────────────────────────────── */
var ctx = null, master = null, dly = null, audioOK = true;
var noteEl = document.getElementById("audioNote");
/* the context can exist and still be silent (autoplay policy, backgrounded
tab) — never claim SOUND ON until it is actually running */
function resumeCtx() {
if (!ctx || ctx.state !== "suspended" || !ctx.resume) return;
try {
var p = ctx.resume();
if (p && p.then) p.then(syncStatus, function () {});
} catch (e) {}
}
function syncStatus() {
if (loop && loop.on) return;
if (ctx && ctx.state === "running") setStatus("SOUND ON", "ok");
else if (!audioOK) setStatus("NO AUDIO", "bad");
else setStatus("TAP PLAY", "");
}
function ensureAudio() {
if (ctx) { resumeCtx(); return true; }
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) { audioOK = false; noteEl.hidden = false; setStatus("NO AUDIO", "bad"); return false; }
try { ctx = new AC(); } catch (e) {
audioOK = false; noteEl.hidden = false; setStatus("NO AUDIO", "bad"); return false;
}
master = ctx.createGain();
master.gain.value = 0.82;
master.connect(ctx.destination);
dly = ctx.createDelay(1.0);
dly.delayTime.value = 0.185;
var fb = ctx.createGain(); fb.gain.value = 0.2;
var wet = ctx.createGain(); wet.gain.value = 0.15;
var damp = ctx.createBiquadFilter();
damp.type = "lowpass"; damp.frequency.value = 1800;
dly.connect(damp); damp.connect(fb); fb.connect(dly);
dly.connect(wet); wet.connect(master);
resumeCtx();
return true;
}
function voice(f, t0, dur, vel) {
var g = ctx.createGain();
var lp = ctx.createBiquadFilter();
lp.type = "lowpass";
lp.Q.value = 0.55;
lp.frequency.setValueAtTime(Math.min(6200, f * 6 + 700), t0);
lp.frequency.exponentialRampToValueAtTime(Math.max(260, f * 2.1), t0 + dur * 0.9);
var o1 = ctx.createOscillator(); o1.type = "triangle"; o1.frequency.value = f;
var o2 = ctx.createOscillator(); o2.type = "sine"; o2.frequency.value = f * 2; o2.detune.value = 4;
var g2 = ctx.createGain(); g2.gain.value = 0.16;
o1.connect(lp); o2.connect(g2); g2.connect(lp); lp.connect(g);
g.connect(master); if (dly) g.connect(dly);
var peak = Math.max(0.004, vel);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(peak, t0 + 0.014);
g.gain.exponentialRampToValueAtTime(peak * 0.5, t0 + 0.3);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o1.start(t0); o2.start(t0);
o1.stop(t0 + dur + 0.06); o2.stop(t0 + dur + 0.06);
o1.onended = function () {
try { o1.disconnect(); o2.disconnect(); g2.disconnect(); lp.disconnect(); g.disconnect(); } catch (e) {}
};
}
function sound(midis, when, dur) {
if (!ensureAudio()) return false;
var t0 = Math.max(when, ctx.currentTime + 0.015);
var vel = 0.2 / Math.max(1, Math.pow(midis.length, 0.42));
for (var i = 0; i < midis.length; i++) voice(freq(midis[i]), t0 + i * 0.017, dur, vel);
return true;
}
/* ── state ──────────────────────────────────────────────────────────────── */
var slots = [
{ r: 0, t: 0, i: 0 }, /* C */
{ r: 9, t: 1, i: 0 }, /* Am */
{ r: 5, t: 0, i: 0 }, /* F */
{ r: 7, t: 2, i: 0 } /* G7 */
];
var sel = 0;
var loop = { on: false, idx: 0, timer: null, next: 0 };
var PRESETS = [
{ n: "Doo-wop", s: [[0,0,0],[9,1,0],[5,0,0],[7,2,0]] },
{ n: "Pop four", s: [[0,0,0],[7,0,0],[9,1,0],[5,0,0]] },
{ n: "Jazz", s: [[2,4,0],[7,2,0],[0,3,0],[9,4,0]] },
{ n: "Hanging", s: [[0,5,0],[0,0,0],[5,5,0],[5,0,0]] },
{ n: "Creepy", s: [[0,0,0],[1,6,0],[2,1,0],[7,2,0]] }
];
/* ── elements ───────────────────────────────────────────────────────────── */
var symEl = document.getElementById("sym");
var symsubEl = document.getElementById("symsub");
var moodEl = document.getElementById("mood");
var roNotes = document.getElementById("roNotes");
var roSteps = document.getElementById("roSteps");
var roBass = document.getElementById("roBass");
var roMidi = document.getElementById("roMidi");
var pianoEl = document.getElementById("piano");
var rootChips = document.getElementById("rootChips");
var typeChips = document.getElementById("typeChips");
var invChips = document.getElementById("invChips");
var ivList = document.getElementById("ivList");
var ivHead = document.getElementById("ivHead");
var stripEl = document.getElementById("strip");
var presetsEl = document.getElementById("presets");
var playBtn = document.getElementById("playBtn");
var loopBtn = document.getElementById("loopBtn");
var loopLbl = document.getElementById("loopLbl");
var statusEl = document.getElementById("status");
var statusTxt = document.getElementById("statusTxt");
var bpmEl = document.getElementById("bpm");
var bpmOut = document.getElementById("bpmOut");
var liveEl = document.getElementById("live");
function setStatus(txt, cls) {
statusTxt.textContent = txt;
statusEl.className = "status" + (cls ? " " + cls : "");
}
/* ── piano build ────────────────────────────────────────────────────────── */
var LO = 60, HI = 84;
var BLACK = { 1: 1, 3: 1, 6: 1, 8: 1, 10: 1 };
var keys = []; /* in midi order */
var focusIdx = 0;
(function buildPiano() {
var whites = 0, m;
for (m = LO; m <= HI; m++) if (!BLACK[m % 12]) whites++;
var ww = 100 / whites, bw = ww * 0.615, seen = 0;
for (m = LO; m <= HI; m++) {
var black = !!BLACK[m % 12];
var el = document.createElement("button");
el.type = "button";
el.className = "key " + (black ? "b" : "w");
el.tabIndex = -1;
el.dataset.midi = String(m);
if (black) {
el.style.left = (seen * ww - bw / 2) + "%";
el.style.width = bw + "%";
} else {
el.style.left = (seen * ww) + "%";
el.style.width = ww + "%";
seen++;
if (m % 12 === 0) el.classList.add("oct");
}
var b1 = document.createElement("span"); b1.className = "kb1";
var b2 = document.createElement("span"); b2.className = "kb2";
if (!black && m % 12 === 0) b2.textContent = "C" + (Math.floor(m / 12) - 1);
el.appendChild(b1); el.appendChild(b2);
el.addEventListener("click", function (ev) {
var mid = +ev.currentTarget.dataset.midi;
focusIdx = keys.indexOf(ev.currentTarget);
roveTab();
hitKey(ev.currentTarget);
if (sound([mid], 0, 1.5)) markPlaying();
});
pianoEl.appendChild(el);
keys.push(el);
}
roveTab();
})();
function roveTab() {
for (var i = 0; i < keys.length; i++) keys[i].tabIndex = (i === focusIdx ? 0 : -1);
}
function hitKey(el) {
el.classList.add("snd");
setTimeout(function () { el.classList.remove("snd"); }, reduce ? 90 : 190);
}
pianoEl.addEventListener("keydown", function (e) {
var k = e.key, moved = -1;
if (k === "ArrowRight") moved = Math.min(keys.length - 1, focusIdx + 1);
else if (k === "ArrowLeft") moved = Math.max(0, focusIdx - 1);
else if (k === "Home") moved = 0;
else if (k === "End") moved = keys.length - 1;
else return;
e.preventDefault();
focusIdx = moved;
roveTab();
keys[focusIdx].focus();
});
/* ── chips build ────────────────────────────────────────────────────────── */
(function buildChips() {
var i;
for (i = 0; i < ROOTS.length; i++) {
(function (idx) {
var b = document.createElement("button");
b.type = "button"; b.className = "chip"; b.textContent = ROOTS[idx].name;
b.setAttribute("aria-pressed", "false");
b.addEventListener("click", function () { edit("r", idx); });
rootChips.appendChild(b);
})(i);
}
for (i = 0; i < TYPES.length; i++) {
(function (idx) {
var b = document.createElement("button");
b.type = "button"; b.className = "chip";
b.innerHTML = TYPES[idx].id + '<span class="sub">' + TYPES[idx].deg.length + " notes</span>";
b.setAttribute("aria-pressed", "false");
b.setAttribute("aria-label", TYPES[idx].id + ", " + TYPES[idx].label + " chord");
b.addEventListener("click", function () { edit("t", idx); });
typeChips.appendChild(b);
})(i);
}
var INV = ["root", "1st", "2nd", "3rd"];
var INVL = ["root, root position", "1st, first inversion", "2nd, second inversion", "3rd, third inversion"];
for (i = 0; i < 4; i++) {
(function (idx) {
var b = document.createElement("button");
b.type = "button"; b.className = "chip"; b.textContent = INV[idx];
b.setAttribute("aria-pressed", "false");
b.setAttribute("aria-label", INVL[idx]);
b.addEventListener("click", function () { edit("i", idx); });
invChips.appendChild(b);
})(i);
}
for (i = 0; i < PRESETS.length; i++) {
(function (idx) {
var b = document.createElement("button");
b.type = "button"; b.className = "pre"; b.textContent = PRESETS[idx].n;
b.addEventListener("click", function () { loadPreset(idx); });
presetsEl.appendChild(b);
})(i);
}
for (i = 0; i < 4; i++) {
(function (idx) {
var b = document.createElement("button");
b.type = "button"; b.className = "slot";
b.innerHTML = '<span class="n">' + (idx + 1) + '</span><span class="s"></span><span class="t"></span>';
b.setAttribute("aria-pressed", "false");
b.addEventListener("click", function () {
if (loop.on) stopLoop();
sel = idx;
render();
preview();
});
stripEl.appendChild(b);
})(i);
}
})();
var rootBtns = rootChips.querySelectorAll(".chip");
var typeBtns = typeChips.querySelectorAll(".chip");
var invBtns = invChips.querySelectorAll(".chip");
var slotBtns = stripEl.querySelectorAll(".slot");
/* ── render ─────────────────────────────────────────────────────────────── */
function render() {
var s = slots[sel];
var c = build(s);
var i;
symEl.innerHTML = "";
var a = document.createElement("span"); a.textContent = c.rootName;
var b = document.createElement("span"); b.className = "sfx"; b.textContent = c.suffix;
symEl.appendChild(a); symEl.appendChild(b);
var invWord = ["ROOT POSITION", "1ST INVERSION", "2ND INVERSION", "3RD INVERSION"][c.inv];
symsubEl.textContent = c.rootName + " " + c.type.label + " · " + invWord;
moodEl.innerHTML = c.type.mood;
var nm = [], md = [], st = [];
for (i = 0; i < c.sorted.length; i++) { nm.push(c.sorted[i].name); md.push(c.sorted[i].midi); }
for (i = 0; i < c.notes.length; i++) st.push(c.notes[i].semis);
roNotes.textContent = nm.join(" ");
roSteps.textContent = st.join(" ");
roBass.textContent = c.bass;
roMidi.textContent = md.join(" ");
/* piano */
var byMidi = {};
for (i = 0; i < c.notes.length; i++) byMidi[c.notes[i].midi] = c.notes[i];
for (i = 0; i < keys.length; i++) {
var el = keys[i], m = +el.dataset.midi, hit = byMidi[m];
var k1 = el.firstChild, k2 = el.lastChild;
el.classList.toggle("lit", !!hit && hit.deg !== 1);
el.classList.toggle("rootk", !!hit && hit.deg === 1);
if (hit) {
k1.textContent = hit.role;
k2.textContent = hit.name;
el.setAttribute("aria-label", hit.name + ", " + ROLE_WORD[hit.deg] + " of " + c.symbol);
} else {
k1.textContent = "";
k2.textContent = (m % 12 === 0) ? "C" + (Math.floor(m / 12) - 1) : "";
el.setAttribute("aria-label", SIMPLE[m % 12] + (Math.floor(m / 12) - 1));
}
}
/* chips */
for (i = 0; i < rootBtns.length; i++) rootBtns[i].setAttribute("aria-pressed", i === s.r ? "true" : "false");
for (i = 0; i < typeBtns.length; i++) typeBtns[i].setAttribute("aria-pressed", i === s.t ? "true" : "false");
for (i = 0; i < invBtns.length; i++) {
invBtns[i].disabled = i >= c.voices;
invBtns[i].setAttribute("aria-pressed", i === c.inv ? "true" : "false");
}
/* intervals */
ivHead.textContent = "— " + c.notes.map(function (x) { return ROLE_WORD[x.deg].replace("the ", ""); }).join(" · ");
ivList.innerHTML = "";
for (i = 0; i < c.notes.length; i++) {
var n = c.notes[i], info = IV[n.semis] || ["", ""];
var li = document.createElement("li");
li.className = "iv" + (n.deg === 1 ? " root" : "");
li.innerHTML =
'<span class="ivn">' + n.name + '</span>' +
'<span class="ivr">' + ROLE_WORD[n.deg] + ' — <b>' + info[0] + '</b></span>' +
'<span class="ivs2">' + (n.semis === 0 ? "start" : "+" + n.semis + " keys") + '</span>' +
'<p class="ivt">' + info[1] + '</p>';
ivList.appendChild(li);
}
/* strip */
for (i = 0; i < 4; i++) {
var cc = build(slots[i]);
slotBtns[i].querySelector(".s").textContent = cc.symbol;
slotBtns[i].querySelector(".t").textContent = cc.rootName + " " + cc.type.label;
slotBtns[i].setAttribute("aria-pressed", i === sel ? "true" : "false");
slotBtns[i].setAttribute("aria-label", "Chord " + (i + 1) + ", " + cc.rootName + " " + cc.type.label);
}
return c;
}
function announce(c) {
liveEl.textContent = c.rootName + " " + c.type.label + ": " +
c.sorted.map(function (x) { return x.name; }).join(", ");
}
/* ── interaction ────────────────────────────────────────────────────────── */
function edit(field, val) {
var s = slots[sel];
if (field === "r") s.r = val;
else if (field === "t") {
s.t = val;
if (s.i >= TYPES[val].semis.length) s.i = 0;
} else s.i = val;
var c = render();
announce(c);
preview();
}
function markPlaying() {
playBtn.classList.remove("nudge");
syncStatus();
}
function currentMidis() {
var c = build(slots[sel]), out = [];
for (var i = 0; i < c.notes.length; i++) out.push(c.notes[i].midi);
return out;
}
function flashChord() {
for (var i = 0; i < keys.length; i++) {
if (keys[i].classList.contains("lit") || keys[i].classList.contains("rootk")) hitKey(keys[i]);
}
}
/* flash first and unconditionally: with no Web Audio the Play button and the
P key would otherwise be completely inert */
function preview() {
flashChord();
if (!audioOK) return;
if (sound(currentMidis(), 0, 1.7)) markPlaying();
}
playBtn.addEventListener("click", preview);
/* ── loop ───────────────────────────────────────────────────────────────── */
function chordSeconds() { return (60 / (+bpmEl.value || 96)) * 2; }
function startLoop() {
if (!ensureAudio()) return;
loop.on = true;
loop.idx = 0;
loop.next = ctx.currentTime + 0.08;
loopBtn.classList.add("primary", "on");
loopBtn.setAttribute("aria-pressed", "true");
loopLbl.textContent = "Stop";
playBtn.classList.remove("nudge");
tick();
}
function stopLoop() {
loop.on = false;
if (loop.timer) { clearTimeout(loop.timer); loop.timer = null; }
loopBtn.classList.remove("primary", "on");
loopBtn.setAttribute("aria-pressed", "false");
loopLbl.textContent = "Loop all four";
for (var i = 0; i < 4; i++) slotBtns[i].classList.remove("playing");
syncStatus();
}
function tick() {
if (!loop.on || !ctx) return;
var dur = chordSeconds();
if (loop.next < ctx.currentTime) loop.next = ctx.currentTime + 0.02;
sel = loop.idx;
var c = render();
var midis = [];
for (var i = 0; i < c.notes.length; i++) midis.push(c.notes[i].midi);
sound(midis, loop.next, dur * 0.96);
flashChord();
for (i = 0; i < 4; i++) slotBtns[i].classList.toggle("playing", i === loop.idx);
setStatus("LOOPING " + (loop.idx + 1) + "/4", "ok");
loop.idx = (loop.idx + 1) % 4;
loop.next += dur;
/* audio-clock pacing when the context is really running; plain wall clock if it
is suspended (blocked autoplay, backgrounded tab) so the loop never strobes */
var wait = (ctx.state === "running") ? (loop.next - ctx.currentTime) * 1000 - 25 : dur * 1000;
loop.timer = setTimeout(tick, Math.max(60, wait));
}
loopBtn.addEventListener("click", function () {
if (loop.on) stopLoop(); else startLoop();
});
function loadPreset(idx) {
var p = PRESETS[idx];
for (var i = 0; i < 4; i++) slots[i] = { r: p.s[i][0], t: p.s[i][1], i: p.s[i][2] };
if (loop.on) {
loop.idx = 0; /* next tick restarts the new pattern from chord 1 */
render();
} else {
sel = 0;
render();
preview();
}
liveEl.textContent = p.n + " progression loaded.";
}
bpmEl.addEventListener("input", function () {
var v = +bpmEl.value;
bpmOut.textContent = v + " BPM";
});
/* ── keyboard shortcuts ─────────────────────────────────────────────────── */
document.addEventListener("keydown", function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target, tag = (t && t.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return;
var k = (e.key || "").toLowerCase();
if (k === "p") { e.preventDefault(); preview(); }
else if (k === "l") { e.preventDefault(); if (loop.on) stopLoop(); else startLoop(); }
else if (k >= "1" && k <= "4") {
e.preventDefault();
if (loop.on) stopLoop();
sel = +k - 1;
render();
preview();
}
});
/* ── go ─────────────────────────────────────────────────────────────────── */
bpmOut.textContent = bpmEl.value + " BPM";
render();
if (!(window.AudioContext || window.webkitAudioContext)) {
audioOK = false; noteEl.hidden = false; setStatus("NO AUDIO", "bad");
}
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.