Jazz chord ear training. Hear a chord, name it against the clock, then see the answer. Triads and sevenths through to altered and extended chords.
---
format: typebulb/v1
name: The Coltrainer
---
**code.tsx**
```tsx
import React, { useEffect, useMemo, useRef, useState } from "react"
import { createRoot } from "react-dom/client"
// A chord has two shapes, and this bulb draws both.
//
// The trace — the chord's waveform. Drawn over exactly one period of the
// chord, transposing it changes nothing at all: every Cmaj7 and every F♯maj7
// makes the same picture. (The idea, and the faint-parts-under-a-bold-sum
// treatment, are lifted from the 12-TET bulb's wave display.)
//
// The wheel — the twelve pitch classes on a clock, the chord's tones joined
// into a polygon. Transposing rotates the polygon and leaves the shape alone.
// dim7 is a square, which is exactly why it answers to four different names.
//
// Both say the same true thing in different languages: what a chord *is* lives
// in its intervals, not its root. Which is what this game asks you to hear.
// ---------- the chords ----------
type PoolId = "triads" | "sevenths" | "colours" | "jazz" | "extended"
// `ivs` are the tones actually voiced, ascending from the root, in root position.
// `degs` are their scale degrees, so the reveal can spell a chord the way it is
// written (E♭ G B♭, never E♭ G A♯): the interval fixes the letter, the root the
// accidental. The jazz pool is voiced the way a pianist plays it — root, the shell
// (3rd and 7th), extensions on top — with the 5th dropped from 9ths, 11ths and
// 13ths unless it is altered, where it is the point.
type Quality = { id: string; name: string; sym: string; ivs: number[]; degs: number[]; pool: PoolId }
const QUALITIES: Quality[] = [
{ id: "maj", name: "Major", sym: "", ivs: [0, 4, 7], degs: [1, 3, 5], pool: "triads" },
{ id: "min", name: "Minor", sym: "m", ivs: [0, 3, 7], degs: [1, 3, 5], pool: "triads" },
{ id: "dim", name: "Diminished", sym: "dim", ivs: [0, 3, 6], degs: [1, 3, 5], pool: "triads" },
{ id: "aug", name: "Augmented", sym: "aug", ivs: [0, 4, 8], degs: [1, 3, 5], pool: "triads" },
{ id: "maj7", name: "Major 7th", sym: "maj7", ivs: [0, 4, 7, 11], degs: [1, 3, 5, 7], pool: "sevenths" },
{ id: "dom7", name: "Dominant 7th", sym: "7", ivs: [0, 4, 7, 10], degs: [1, 3, 5, 7], pool: "sevenths" },
{ id: "min7", name: "Minor 7th", sym: "m7", ivs: [0, 3, 7, 10], degs: [1, 3, 5, 7], pool: "sevenths" },
{ id: "m7b5", name: "Half-diminished 7th", sym: "m7♭5", ivs: [0, 3, 6, 10], degs: [1, 3, 5, 7], pool: "sevenths" },
{ id: "dim7", name: "Diminished 7th", sym: "dim7", ivs: [0, 3, 6, 9], degs: [1, 3, 5, 7], pool: "sevenths" },
{ id: "sus2", name: "Sus2", sym: "sus2", ivs: [0, 2, 7], degs: [1, 2, 5], pool: "colours" },
{ id: "sus4", name: "Sus4", sym: "sus4", ivs: [0, 5, 7], degs: [1, 4, 5], pool: "colours" },
{ id: "maj6", name: "Major 6th", sym: "6", ivs: [0, 4, 7, 9], degs: [1, 3, 5, 6], pool: "colours" },
{ id: "min6", name: "Minor 6th", sym: "m6", ivs: [0, 3, 7, 9], degs: [1, 3, 5, 6], pool: "colours" },
{ id: "add9", name: "Add 9", sym: "add9", ivs: [0, 4, 7, 14], degs: [1, 3, 5, 9], pool: "colours" },
{ id: "add4", name: "Add 4", sym: "add4", ivs: [0, 4, 5, 7], degs: [1, 3, 4, 5], pool: "colours" },
{ id: "minadd9", name: "Minor add 9", sym: "m(add9)", ivs: [0, 3, 7, 14], degs: [1, 3, 5, 9], pool: "colours" },
{ id: "minadd11", name: "Minor add 11", sym: "m(add11)", ivs: [0, 3, 7, 17], degs: [1, 3, 5, 11], pool: "colours" },
{ id: "adds11", name: "Add ♯11", sym: "add♯11", ivs: [0, 4, 7, 18], degs: [1, 3, 5, 11], pool: "colours" },
{ id: "dom7sus2", name: "7sus2", sym: "7sus2", ivs: [0, 2, 7, 10], degs: [1, 2, 5, 7], pool: "colours" },
{ id: "maj9", name: "Major 9th", sym: "maj9", ivs: [0, 4, 11, 14], degs: [1, 3, 7, 9], pool: "jazz" },
{ id: "dom9", name: "Dominant 9th", sym: "9", ivs: [0, 4, 10, 14], degs: [1, 3, 7, 9], pool: "jazz" },
{ id: "min9", name: "Minor 9th", sym: "m9", ivs: [0, 3, 10, 14], degs: [1, 3, 7, 9], pool: "jazz" },
{ id: "dom7b9", name: "Dominant 7th ♭9", sym: "7♭9", ivs: [0, 4, 10, 13], degs: [1, 3, 7, 9], pool: "jazz" },
{ id: "dom7s9", name: "Dominant 7th ♯9", sym: "7♯9", ivs: [0, 4, 10, 15], degs: [1, 3, 7, 9], pool: "jazz" },
{ id: "dom13", name: "Dominant 13th", sym: "13", ivs: [0, 4, 10, 14, 21], degs: [1, 3, 7, 9, 13], pool: "extended" },
// The ♯11 chords keep their natural fifth. Dropped, the ♯11 simply *is* the
// flat five, and 7♯11 and 7♭5 become one chord under two names.
{ id: "maj7s11", name: "Major 7th ♯11", sym: "maj7♯11", ivs: [0, 4, 7, 11, 18], degs: [1, 3, 5, 7, 11], pool: "extended" },
{ id: "dom7s11", name: "Dominant 7th ♯11", sym: "7♯11", ivs: [0, 4, 7, 10, 18], degs: [1, 3, 5, 7, 11], pool: "extended" },
{ id: "dom7b5", name: "Dominant 7th ♭5", sym: "7♭5", ivs: [0, 4, 6, 10], degs: [1, 3, 5, 7], pool: "jazz" },
{ id: "dom7s5", name: "Dominant 7th ♯5", sym: "7♯5", ivs: [0, 4, 8, 10], degs: [1, 3, 5, 7], pool: "jazz" },
{ id: "alt", name: "Altered dominant", sym: "7alt", ivs: [0, 4, 10, 15, 20], degs: [1, 3, 7, 9, 13], pool: "extended" },
{ id: "mmaj7", name: "Minor-major 7th", sym: "m(maj7)", ivs: [0, 3, 7, 11], degs: [1, 3, 5, 7], pool: "jazz" },
{ id: "sus9", name: "9sus4", sym: "9sus4", ivs: [0, 5, 10, 14], degs: [1, 4, 7, 9], pool: "jazz" },
{ id: "maj7s5", name: "Major 7th ♯5", sym: "maj7♯5", ivs: [0, 4, 8, 11], degs: [1, 3, 5, 7], pool: "jazz" },
{ id: "sus7", name: "7sus4", sym: "7sus4", ivs: [0, 5, 7, 10], degs: [1, 4, 5, 7], pool: "jazz" },
{ id: "sus7b9", name: "7sus4 ♭9", sym: "7sus♭9", ivs: [0, 5, 10, 13], degs: [1, 4, 7, 9], pool: "jazz" },
{ id: "dimmaj7", name: "Diminished major 7th", sym: "dim(maj7)", ivs: [0, 3, 6, 11], degs: [1, 3, 5, 7], pool: "jazz" },
{ id: "maj7b5", name: "Major 7th ♭5", sym: "maj7♭5", ivs: [0, 4, 6, 11], degs: [1, 3, 5, 7], pool: "jazz" },
{ id: "maj7sus4", name: "maj7sus4", sym: "maj7sus4", ivs: [0, 5, 7, 11], degs: [1, 4, 5, 7], pool: "jazz" },
{ id: "m7b9", name: "Minor 7th ♭9", sym: "m7♭9", ivs: [0, 3, 10, 13], degs: [1, 3, 7, 9], pool: "jazz" },
// Five notes and up — which is the whole line between the hard pool and this
// one. A round's four options are near relatives, so the more tones a chord
// has the finer the distinction: these are chords a single altered tone apart.
{ id: "min11", name: "Minor 11th", sym: "m11", ivs: [0, 3, 10, 14, 17], degs: [1, 3, 7, 9, 11], pool: "extended" },
{ id: "six9", name: "6/9", sym: "6/9", ivs: [0, 4, 7, 9, 14], degs: [1, 3, 5, 6, 9], pool: "extended" },
{ id: "mmaj9", name: "Minor-major 9th", sym: "m(maj9)", ivs: [0, 3, 7, 11, 14], degs: [1, 3, 5, 7, 9], pool: "extended" },
{ id: "dom11", name: "Dominant 11th", sym: "11", ivs: [0, 7, 10, 14, 17], degs: [1, 5, 7, 9, 11], pool: "extended" },
{ id: "maj9s5", name: "Major 9th ♯5", sym: "maj9♯5", ivs: [0, 4, 8, 11, 14], degs: [1, 3, 5, 7, 9], pool: "extended" },
{ id: "m11b5", name: "Half-diminished 11th", sym: "m11♭5", ivs: [0, 3, 6, 10, 17], degs: [1, 3, 5, 7, 11], pool: "extended" },
{ id: "mmaj11", name: "Minor-major 11th", sym: "m(maj11)", ivs: [0, 3, 11, 14, 17], degs: [1, 3, 7, 9, 11], pool: "extended" },
{ id: "m7s11", name: "Minor 7th ♯11", sym: "m7♯11", ivs: [0, 3, 7, 10, 18], degs: [1, 3, 5, 7, 11], pool: "extended" },
{ id: "maj13", name: "Major 13th", sym: "maj13", ivs: [0, 4, 11, 14, 21], degs: [1, 3, 7, 9, 13], pool: "extended" },
{ id: "min13", name: "Minor 13th", sym: "m13", ivs: [0, 3, 10, 14, 21], degs: [1, 3, 7, 9, 13], pool: "extended" },
{ id: "dom13b9", name: "Dominant 13th ♭9", sym: "13♭9", ivs: [0, 4, 10, 13, 21], degs: [1, 3, 7, 9, 13], pool: "extended" },
{ id: "dom7b13", name: "Dominant 7th ♭13", sym: "7♭13", ivs: [0, 4, 7, 10, 20], degs: [1, 3, 5, 7, 13], pool: "extended" },
{ id: "dom7b9b13", name: "Dominant 7th ♭9♭13", sym: "7♭9♭13", ivs: [0, 4, 10, 13, 20], degs: [1, 3, 7, 9, 13], pool: "extended" },
{ id: "min69", name: "Minor 6/9", sym: "m6/9", ivs: [0, 3, 7, 9, 14], degs: [1, 3, 5, 6, 9], pool: "extended" },
{ id: "m9b5", name: "Half-diminished 9th", sym: "m9♭5", ivs: [0, 3, 6, 10, 14], degs: [1, 3, 5, 7, 9], pool: "extended" },
{ id: "dom13s9", name: "Dominant 13th ♯9", sym: "13♯9", ivs: [0, 4, 10, 15, 21], degs: [1, 3, 7, 9, 13], pool: "extended" },
{ id: "dom13s11", name: "Dominant 13th ♯11", sym: "13♯11", ivs: [0, 4, 10, 14, 18, 21], degs: [1, 3, 7, 9, 11, 13], pool: "extended" },
{ id: "maj9s11", name: "Major 9th ♯11", sym: "maj9♯11", ivs: [0, 4, 7, 11, 14, 18], degs: [1, 3, 5, 7, 9, 11], pool: "extended" },
{ id: "dom9s5", name: "Dominant 9th ♯5", sym: "9♯5", ivs: [0, 4, 8, 10, 14], degs: [1, 3, 5, 7, 9], pool: "extended" },
{ id: "dom9b5", name: "Dominant 9th ♭5", sym: "9♭5", ivs: [0, 4, 6, 10, 14], degs: [1, 3, 5, 7, 9], pool: "extended" },
{ id: "sus13", name: "13sus4", sym: "13sus4", ivs: [0, 5, 10, 14, 21], degs: [1, 4, 7, 9, 13], pool: "extended" },
{ id: "maj7b9", name: "Major 7th ♭9", sym: "maj7♭9", ivs: [0, 4, 7, 11, 13], degs: [1, 3, 5, 7, 9], pool: "extended" },
// Six at a time. The ♯11 rule from above still holds, except where the chord
// already names its thirteenth: 13♯11 and maj13♯11 drop the fifth like the
// other thirteenths, because nothing is left for the ♯11 to be mistaken for.
{ id: "dom9s11", name: "Dominant 9th ♯11", sym: "9♯11", ivs: [0, 4, 7, 10, 14, 18], degs: [1, 3, 5, 7, 9, 11], pool: "extended" },
{ id: "dom7b9s11", name: "Dominant 7th ♭9♯11", sym: "7♭9♯11", ivs: [0, 4, 7, 10, 13, 18], degs: [1, 3, 5, 7, 9, 11], pool: "extended" },
{ id: "dom7s9s11", name: "Dominant 7th ♯9♯11", sym: "7♯9♯11", ivs: [0, 4, 7, 10, 15, 18], degs: [1, 3, 5, 7, 9, 11], pool: "extended" },
{ id: "maj13s11", name: "Major 13th ♯11", sym: "maj13♯11", ivs: [0, 4, 11, 14, 18, 21], degs: [1, 3, 7, 9, 11, 13], pool: "extended" },
{ id: "six9s11", name: "6/9 ♯11", sym: "6/9♯11", ivs: [0, 4, 7, 9, 14, 18], degs: [1, 3, 5, 6, 9, 11], pool: "extended" },
{ id: "min69s11", name: "Minor 6/9 ♯11", sym: "m6/9♯11", ivs: [0, 3, 7, 9, 14, 18], degs: [1, 3, 5, 6, 9, 11], pool: "extended" },
]
// A quality's `pool` is the family it belongs to; a PoolSetting is how hard a
// game you asked for. Keeping the two apart lets the ladder be regrouped without
// anyone touching the chord table.
type PoolSetting = "basic" | "medium" | "hard" | "coltrane" | "all"
const POOLS: { id: PoolSetting; label: string; has: (q: Quality) => boolean }[] = [
{ id: "basic", label: "Basic", has: q => q.pool === "triads" },
{ id: "medium", label: "Med", has: q => q.pool === "sevenths" || q.pool === "colours" },
{ id: "hard", label: "Hard", has: q => q.pool === "jazz" },
{ id: "coltrane", label: "Coltrane", has: q => q.pool === "extended" },
{ id: "all", label: "All", has: () => true },
]
const poolOf = (id: PoolSetting) => QUALITIES.filter(POOLS.find(p => p.id === id)!.has)
const symOf = (q: Quality) => q.sym || "maj"
type Settings = { pool: PoolSetting; inversions: boolean; clock: number }
/* Opening on Basic. A first chord you can name is what makes someone play a
second one, and being too easy is the cheap mistake here: the opening line
asks how good your ear is and points at the control, so anyone who finds
triads beneath them is one tap from saying so. Arriving on a pool you cannot
touch has no equivalent way out, because you learn nothing from it. */
const DEFAULTS: Settings = { pool: "basic", inversions: false, clock: 30 }
// Seconds, not fast/medium/slow: the difficulty ladder is already a
// basic/medium/hard scale, and a second one beside it reads as another
// difficulty rather than as a clock.
const SPEEDS = [{ v: 5, label: "5s" }, { v: 10, label: "10s" }, { v: 30, label: "30s" }]
const VOICINGS = [{ v: false, label: "Root position" }, { v: true, label: "With inversions" }]
const ROUNDS = 10
/* The run, as the 0..1 the stylesheet wants. Capped at a full set, so one set
spends the whole range and no more. Set once, on the board's root, and read by
inheritance from there, so there is one place it comes from and nothing below
may redeclare it.
What it buys is the SIZE OF THE LEAP, and this took three tries to get right.
Twice it was spent on a standing level instead: a wider glow, then a taller
flame, both of which measured correctly and neither of which could be seen.
A standing level fails here for a reason particular to this candle. It moves.
The wax burns down thirty or forty pixels across a round while the run adds
one, so the signal sits under a confound twenty times its size on the same
object, and there is nothing beside it holding still to measure against. By
the time the flame is visibly taller you have forgotten what it looked like
ten chords ago.
A leap has neither problem. It is compared against the last leap, seconds old,
in the same place, and an eye is good at that. So the run multiplies the
flare. And it has to be multiplied HARD. Three passes were spent on changes
that measured correctly and could not be seen, because a difference of 20 or
30 per cent on a 20px shape that appears for 150ms is not a difference anyone
notices. So: a cold right answer lifts the flame by about a third, a long run
nearly triples it, and past a couple in a row the leap throws embers, which no
amount of squinting is needed to tell apart from one that does not. */
const runOf = (streak: number) => clamp(streak, 0, ROUNDS) / ROUNDS
// ---------- names ----------
// Chord-chart spellings for the twelve roots: D♭ rather than C♯, F♯ rather than G♭
// — unless the root's other name spells this chord with fewer accidentals (D♭m9 is
// D♭ F♭ C♭ E♭; C♯m9 is C♯ E B D♯), or the chart name forces a double accidental
// (D♭dim7 wants C♭♭). Ties keep the chart name.
const ROOT_NAMES = ["C", "D♭", "D", "E♭", "E", "F", "F♯", "G", "A♭", "A", "B♭", "B"]
const ALT_NAMES: Record<number, string> = { 1: "C♯", 3: "D♯", 6: "G♭", 8: "G♯", 10: "A♯" }
const SHARP_NAMES = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]
const FLAT_NAMES = ["C", "D♭", "D", "E♭", "E", "F", "G♭", "G", "A♭", "A", "B♭", "B"]
const LETTERS = "CDEFGAB"
const NAT = [0, 2, 4, 5, 7, 9, 11]
const rand = (n: number) => Math.floor(Math.random() * n)
const pick = <T,>(xs: T[]) => xs[rand(xs.length)]
const shuffle = <T,>(xs: T[]) => {
const a = xs.slice()
for (let i = a.length - 1; i > 0; i--) { const j = rand(i + 1); [a[i], a[j]] = [a[j], a[i]] }
return a
}
const lower = (s: string) => s[0].toLowerCase() + s.slice(1)
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
const clamp01 = (v: number) => clamp(v, 0, 1)
// A note, an interval or a degree, reduced to the twelve
const pcOf = (n: number) => ((n % 12) + 12) % 12
const pcsOf = (ns: number[]) => [...new Set(ns.map(pcOf))]
const PCS = Array.from({ length: 12 }, (_, i) => i)
type Chord = { rootPc: number; rootName: string; rootMidi: number; q: Quality; inv: number; notes: number[] }
// The keyboard runs C3–B5: roots sit in C3–B3, and no chord in the table, in any
// inversion, climbs past A♭5.
const KB_LO = 48, KB_HI = 83
const LOW_ROOT = 48
// Inversion k: the lowest k tones of the chord's own octave go up one. Extensions
// (the 9th, 11th, 13th — anything already above the octave) stay on top, where
// they belong, so the bass is always the root, 3rd, 5th, 6th or 7th, and only the
// tones under the octave count towards how many inversions a quality has.
const invBase = (q: Quality) => q.ivs.filter(iv => iv < 12).length
const chordOf = (q: Quality, rootMidi: number, inv: number): Chord => ({
rootPc: pcOf(rootMidi), rootName: spellRoot(pcOf(rootMidi), q), rootMidi, q, inv,
notes: q.ivs.map((iv, i) => rootMidi + iv + (i < inv ? 12 : 0)).sort((a, b) => a - b),
})
function makeChord(s: Settings, prev: Chord | null): Chord {
const pool = poolOf(s.pool)
let q = pick(pool)
if (prev && pool.length > 1 && q.id === prev.q.id) q = pick(pool.filter(x => x.id !== prev.q.id))
return chordOf(q, LOW_ROOT + rand(12), s.inversions ? rand(invBase(q)) : 0)
}
const maskOf = (ns: number[]) => ns.reduce((m, n) => m | (1 << pcOf(n)), 0)
const pcMask = (root: number, ivs: number[]) => maskOf(ivs.map(iv => root + iv))
// What an answer would have sounded like: the option's kind on the heard chord's
// root, in the same register and the same inversion, so the comparison isolates
// the one thing that differs. The right answer is the chord itself, voicing and
// all.
// The whole chord, not just its notes: auditioning an answer has to be able to
// name and spell what it is showing, and the spelling depends on the kind (the
// root of a m(maj7) is spelled under different rules than the root of a 13♭9).
const optionChord = (q: Quality, c: Chord): Chord =>
q.id === c.q.id ? c : chordOf(q, c.rootMidi, Math.min(c.inv, invBase(q) - 1))
const optionNotes = (q: Quality, c: Chord) => optionChord(q, c).notes
const popcount = (m: number) => { let c = 0; while (m) { m &= m - 1; c++ } return c }
// how much of one chord is the other — shared pitch classes over the union, both on
// the same root. A 9 and a 13 score 4/5; a major and an altered dominant 2/6.
const similarity = (a: Quality, b: Quality) => {
const x = pcMask(0, a.ivs), y = pcMask(0, b.ivs)
return popcount(x & y) / popcount(x | y)
}
// The other qualities in the pool, nearest first, with enough jitter that the same
// three neighbours don't turn up every time. This is what makes four choices hard:
// a 7♭9 is asked against 7♯9, 7alt and 9 — not against major, minor and sus2.
const neighbours = (c: Chord, pool: Quality[]) =>
pool.filter(q => q.id !== c.q.id)
.map(q => ({ q, s: similarity(q, c.q) + Math.random() * 0.3 }))
.sort((a, b) => b.s - a.s).map(x => x.q)
// Is this option the same pitch classes as the chord under another name? C6 is
// Am7 over C; Csus2 is Gsus4 over C; and the symmetric kinds are one chord with
// several names — Cdim7 = E♭dim7 = G♭dim7 = Adim7, Caug = Eaug = A♭aug,
// C7♯11 = G♭7♯11.
//
// Checked in every voicing, not only under inversions. The rule used to let these
// through in root position on the grounds that the bass settles it, but an option
// reading "E♭ diminished 7th" does not say "in root position", so on the board it
// reads as a second right answer — and by ear the root is not even asked, which
// makes a transposed twin the same answer under a different name.
// In root position this cannot happen and the guard is off. An option names a
// kind and nothing else, and the bass you hear is the root, so a kind is right
// exactly when its intervals above that bass are the ones you heard — and no two
// kinds in a pool share those. Cm7 and E♭6 are the same five pitch classes, but
// the one with C underneath is Cm7 and only Cm7.
//
// Turn inversions on and the bass stops being the root, so the chord really can
// be read from another root under another name, and then the twin has to go.
// (This used to run in every voicing, from back when an option could name a root
// as well as a kind — "C dim7" against "E♭ dim7" is genuinely two names for one
// set of notes. Without that mode there is nothing left for it to catch.)
function clashes(q: Quality, c: Chord, s: Settings): boolean {
if (!s.inversions) return false
const m = maskOf(c.notes)
return PCS.some(r => pcMask(r, q.ivs) === m)
}
// An option names a kind, never a root: the root is never asked, so it is never
// offered. Which also means two options can only ever differ by interval.
function makeOptions(c: Chord, s: Settings): { options: Quality[]; answer: number } {
const out = [c.q]
// Options are compared as pitch-class sets, not as names: two kinds spelling
// one set of notes would be one answer under two labels. The answer's own set
// seeds this, so nothing can shadow it.
const sets = new Set([pcMask(0, c.q.ivs)])
// nearest kinds first, so the four choices are close relatives
for (const q of neighbours(c, poolOf(s.pool))) {
if (out.length >= 4) break
const set = pcMask(0, q.ivs)
if (sets.has(set) || clashes(q, c, s)) continue
sets.add(set); out.push(q)
}
const options = shuffle(out)
return { options, answer: options.indexOf(c.q) }
}
// Spell the tones by degree from a given root name, and price the result:
// accidentals cost 1, the ones that land on a white key (F♭, C♭, E♯, B♯) cost 2,
// being the ones a reader stumbles on. A tone that would need a double accidental
// gets a plain pitch-class name instead (E7♯9's F𝄪 is written G by everyone) at a
// cost of 3, so a spelling that manages without always wins.
function spellWith(rn: string, q: Quality): { names: string[]; cost: number } {
const li = LETTERS.indexOf(rn[0])
const racc = rn.length > 1 ? (rn[1] === "♯" ? 1 : -1) : 0
const names: string[] = []
let cost = 0
for (let i = 0; i < q.ivs.length; i++) {
const L = (li + q.degs[i] - 1) % 7
const want = (NAT[li] + racc + q.ivs[i]) % 12
const acc = ((((want - NAT[L]) % 12) + 18) % 12) - 6
if (Math.abs(acc) > 1) { names.push((acc > 0 ? SHARP_NAMES : FLAT_NAMES)[want]); cost += 3 }
else { names.push(LETTERS[L] + (acc === 1 ? "♯" : acc === -1 ? "♭" : "")); cost += Math.abs(acc) * (NAT.includes(want) ? 2 : 1) }
}
return { names, cost }
}
// The root name this quality spells best under: the cheaper of the chart name and
// its enharmonic twin, the chart name on a tie.
function spellRoot(pc: number, q: Quality): string {
const alt = ALT_NAMES[pc]
if (!alt) return ROOT_NAMES[pc]
return spellWith(alt, q).cost < spellWith(ROOT_NAMES[pc], q).cost ? alt : ROOT_NAMES[pc]
}
const spell = (c: Chord): string[] => spellWith(c.rootName, c.q).names
function nameToPc(n: string) {
let pc = NAT[LETTERS.indexOf(n[0])]
for (const ch of n.slice(1)) pc += ch === "♯" ? 1 : ch === "♭" ? -1 : 0
return pcOf(pc)
}
// "A♭m7/C♭" — slash bass when an inversion puts another tone underneath
function symbolOf(c: Chord): string {
const names = spell(c)
const bassPc = c.notes[0] % 12
const bi = c.q.ivs.findIndex(iv => (c.rootPc + iv) % 12 === bassPc)
const slash = bassPc !== c.rootPc && bi >= 0 ? "/" + names[bi] : ""
return c.rootName + c.q.sym + slash
}
// ---------- sound ----------
let ac: AudioContext | null = null
let bus: GainNode | null = null
function audio(): AudioContext | null {
try {
if (!ac) {
const AC = (window as any).AudioContext || (window as any).webkitAudioContext
if (!AC) return null
ac = new AC() as AudioContext
bus = ac.createGain(); bus.gain.value = 0.8
const comp = ac.createDynamicsCompressor()
comp.threshold.value = -18; comp.ratio.value = 4
bus.connect(comp); comp.connect(ac.destination)
keepAlive(ac)
// A device that has gone back to sleep has to be woken again, so a context
// that leaves `running` forfeits its warm badge.
ac.addEventListener("statechange", () => { if (ac && ac.state !== "running") warm = false })
}
if (ac.state === "suspended") ac.resume().catch(() => { /* needs a gesture yet */ })
return ac
} catch { return null }
}
const hz = (m: number) => 440 * Math.pow(2, (m - 69) / 12)
// An electric-piano-ish tone: a few sine partials, the upper ones dying first, so
// the attack has some bite and the tail is nearly pure.
const PARTIALS: [number, number][] = [[1, 1], [2, 0.45], [3, 0.2], [4, 0.1], [5, 0.05]]
function voice(a: AudioContext, m: number, t0: number, vel: number, tail0 = 2.4) {
for (const [k, amp] of PARTIALS) {
const o = a.createOscillator(); o.type = "sine"; o.frequency.value = hz(m) * k
const g = a.createGain()
const peak = amp * vel
const tail = tail0 / Math.sqrt(k)
g.gain.setValueAtTime(0, t0)
g.gain.linearRampToValueAtTime(peak, t0 + 0.008)
g.gain.exponentialRampToValueAtTime(peak * 0.5, t0 + 0.35)
g.gain.exponentialRampToValueAtTime(0.0005, t0 + tail)
o.connect(g); g.connect(bus!); o.start(t0); o.stop(t0 + tail + 0.05)
}
}
/* Getting the first sound of the session out intact.
Two clocks matter and `currentTime` is the wrong one. `resume()` settling, and
the context clock starting to move, both say the graph is being *rendered*;
neither says the output device is *playing*. Opening that device costs anywhere
from a few milliseconds to most of a second — Bluetooth, a sleeping DAC — and
everything rendered into that gap is computed correctly and thrown away. That
is a chord arriving with its attack already gone, which is what this used to do.
`getOutputTimestamp().contextTime` is the one number that tells the truth: the
position of the stream the hardware is actually consuming. It stands still while
the device opens, so wait for it to move and only then schedule. Waiting for it
to *move* rather than to be non-zero is what makes it work a second time: after
a suspend it resumes from its old value, not from zero.
That got the first chord out. It did not get the fourth. A device sleeps again
the moment nothing is feeding it, and it does that silently: the context stays
`running`, `statechange` never fires, and nothing in the graph says the DAC has
powered down. So a badge earned at the first gesture was still being trusted a
minute later, and the wait that had been so carefully measured was skipped at
precisely the two clicks that follow a silence — Start, after reading the setup;
Again, after reading the results. Mid-set the chords come close enough together
to hold the device open, which is why it only ever went missing at those two.
Two answers, in order. A keep-alive, so the stream never empties and there is no
idle to fall into. And an expiry on the badge, so that if it sleeps anyway the
next sound measures again instead of assuming: that costs a poll tick on a
device that is up, and saves the attack on one that is not.
The wait is paid once, and mostly before the click that wants the sound — see
`unlock`. Everything after goes out at the usual few tens of milliseconds. */
let warm = false
const LEAD = 0.03 // scheduling headroom, once the device is up
/* And the headroom for a sound that follows a silence, where it may not be. This
is the part that stopped being clever. Whether the DAC is open is not a thing
the platform will tell you — `getOutputTimestamp` reports frames handed to the
sink, which start moving well before the hardware plays them, so a probe built
on it reports a 13ms warm-up on a device that takes half a second to wake. So
don't ask: wait. A third of a second of pre-roll puts the device's opening
window over silence instead of over the attack, and the keep-alive above is
feeding it the whole time. It is paid only on a cold sound, and paid where a
third of a second reads as the app answering rather than as lag — the click
that starts a set, not the one that plays a key. */
const COLD = 0.35
const WARMUP = 1200 // ms to wait on a device that never reports itself
const IDLE = 1500 // ms of silence after which a warm badge is only a memory
// when a sound was last asked for — the gap between asks is what puts a device down
let lastAsk = -1e9
// what the warm-up cost, read back by the `audio` probe
const warmup = { ms: -1, live: false, rechecks: 0 }
/* Holding the device open. The cheapest way to stop a stream from being closed
under you is to never stop feeding it, so a loop runs from the moment the graph
exists and never ends. It carries noise rather than digital silence because a
silent stream is the thing the sleepier stacks — Bluetooth above all — watch for.
At -90 dBFS it sits under the floor of a 16-bit path, which is to say under
anything anyone can hear, and it goes straight to the output: nothing this quiet
has any business passing through the compressor the chords share. */
let alive: AudioBufferSourceNode | null = null
function keepAlive(a: AudioContext) {
if (alive) return
try {
const n = Math.floor(a.sampleRate * 0.5)
const b = a.createBuffer(1, n, a.sampleRate)
const d = b.getChannelData(0)
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * 3e-5
const s = a.createBufferSource()
s.buffer = b; s.loop = true
s.connect(a.destination); s.start()
alive = s
} catch { /* no keep-alive; the expiry below still covers it */ }
}
/** Where the output stream has got to, as the hardware sees it — falling back to
the render clock, which is all some browsers will tell us. */
function outPos(a: AudioContext): number {
try {
if (typeof a.getOutputTimestamp === "function") return a.getOutputTimestamp().contextTime ?? 0
} catch { /* refused while suspended, on some builds */ }
return a.currentTime
}
function whenReady(a: AudioContext, then: () => void) {
if (warm) { then(); return }
const t0 = performance.now(), mark = outPos(a)
const poll = () => {
const running = a.state === "running"
const live = running && outPos(a) > mark
if (!live && performance.now() - t0 < WARMUP) { setTimeout(poll, 12); return }
// Warm even on a timeout, so long as the context was actually up: a browser
// that never reports its stream position is one there is no point
// interrogating before every note. A context still waiting on a gesture is
// the other case — it has not had its chance yet, so leave it cold and let
// the next thing that wants a sound ask again.
if (running) {
warm = true
warmup.live = live
warmup.ms = Math.round(performance.now() - t0)
}
then()
}
poll()
}
function schedule(play: (a: AudioContext, t0: number) => void, preroll = false) {
const a = audio(); if (!a || !bus) return
// A badge earned before a long silence is not evidence about now.
const now = performance.now()
const cold = now - lastAsk > IDLE
if (cold && warm) { warm = false; warmup.rechecks++ }
lastAsk = now
const lead = cold && preroll ? COLD : LEAD
const go = () => whenReady(a, () => play(a, a.currentTime + lead))
if (a.state === "running") go()
else a.resume().then(go, go)
}
/* The device can only be opened by a gesture, so open it on the *earliest* one:
the pointer going down, which leads the click that asks for a chord by the
length of the press — and usually by a whole screenful of reading the setup.
By the time anything wants to be heard the warm-up is normally over. */
function unlock() {
const a = audio(); if (!a) return
// Old iOS wants a source actually started inside the gesture, not just a resume
try {
const s = a.createBufferSource()
s.buffer = a.createBuffer(1, 1, a.sampleRate); s.connect(a.destination); s.start()
} catch { /* nothing to salvage */ }
lastAsk = performance.now()
whenReady(a, () => { })
}
/* A clock probe used to sit here, sampling the render clock against
`getOutputTimestamp` for 900ms after every chord and logging a verdict, to
settle whether a swallowed attack was the device opening under the note or
the renderer running late. It answered: the device, which is what the pre-roll
and the keep-alive above are for. It then reported `clean` on every chord for
the rest of its life, so it has been taken out. Its cost was a 16ms interval
and a log line per chord, and its findings are written up where the fixes are.
If the attack ever goes missing again, that is the shape of the probe to
rebuild: the two failures are indistinguishable from the speaker and want
opposite fixes, so guessing does not converge. */
function playChord(notes: number[], then?: () => void) {
schedule((a, t0) => {
for (const m of notes) voice(a, m, t0, 0.2 / Math.sqrt(notes.length))
atStrike(a, t0, then) // the trace strikes when the chord does
}, true)
}
/** Fire `then` when the notes actually sound, which on a cold device is a
pre-roll after the click that asked for them. */
function atStrike(a: AudioContext, t0: number, then?: () => void) {
if (then) window.setTimeout(then, Math.max(0, (t0 - a.currentTime) * 1000))
}
// a single key, clicked — the same voice, a touch shorter
function playNote(m: number) {
schedule((a, t0) => voice(a, m, t0, 0.2, 1.8))
}
/** The same notes one at a time, each left ringing, so the chord assembles
itself out of its own spelling and you hear the intervals stack up. */
const ARP_GAP = 0.32
function playArpeggio(notes: number[], then?: () => void) {
schedule((a, t0) => {
notes.forEach((m, i) => voice(a, m, t0 + i * ARP_GAP, 0.24, 2.3))
atStrike(a, t0, then) // …and the keys light with the notes, for the same reason
}, true)
}
const noteName = (m: number) => ROOT_NAMES[m % 12] + (Math.floor(m / 12) - 1)
// One empty array, not a fresh one per render: `Trace` and `Keyboard` key their
// work off the identity of what they are given.
const NO_NOTES: number[] = []
// tiny right/wrong cues, well under the chord's level
// A tap, never a tune. Anything with a pitch in it is a second chord landing on
// top of the one you are still holding in your head, so both cues are filtered
// noise: a dry tick when you land it, a soft thud when you do not.
let noiseBuf: AudioBuffer | null = null
function noise(a: AudioContext) {
if (!noiseBuf || noiseBuf.sampleRate !== a.sampleRate) {
const n = Math.floor(a.sampleRate * 0.4)
noiseBuf = a.createBuffer(1, n, a.sampleRate)
const d = noiseBuf.getChannelData(0)
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1
}
return noiseBuf
}
function cue(good: boolean) {
// Through `schedule` like everything else. A cue follows a stretch of silence as
// often as a chord does — you can sit on a question for a minute — and reaching
// for `currentTime` itself is how it kept being swallowed.
schedule((a, t) => {
const s = a.createBufferSource(); s.buffer = noise(a)
const f = a.createBiquadFilter()
const g = a.createGain()
const dur = good ? 0.05 : 0.19
if (good) { f.type = "bandpass"; f.frequency.value = 3400; f.Q.value = 0.8; g.gain.setValueAtTime(0.07, t) }
else {
// The thud used to sit under 240Hz, which is below what a laptop speaker
// reproduces, so on most machines a wrong answer made no sound at all. Same
// character, moved up into the range a small speaker actually has, and swept
// downward across its length so it still lands like a door closing rather
// than sitting there as a hiss. Still noise: nothing here has a pitch.
f.type = "lowpass"; f.Q.value = 0.7
f.frequency.setValueAtTime(900, t)
f.frequency.exponentialRampToValueAtTime(260, t + dur)
g.gain.setValueAtTime(0.34, t)
}
g.gain.exponentialRampToValueAtTime(0.0002, t + dur)
s.connect(f).connect(g).connect(bus!)
s.start(t, Math.random() * 0.3); s.stop(t + dur + 0.05)
})
}
/* ---------- the trace ----------
The chord's own waveform, summed from the same partials the synth uses, so
the picture is of the sound and not a decoration next to it.
Two windows, and the difference between them is the whole point:
"heard" — a fixed 42 ms slice. How many cycles fit depends on the root, so
this is the sound you just heard, in the register you heard it.
"shape" — exactly one period of the chord. The root cancels out entirely:
every voicing of a maj7 draws the identical curve. That is the
shape of the quality, which is what you were asked to name.
Drawn on canvas, which has no cascade, so the colours are read back off the
page rather than restated here and left to drift. */
// The synth's own partials, summed. The claim above, that the picture is of the
// sound, holds because there is no second list here to drift from.
const PARTIAL_SUM = PARTIALS.reduce((s, [, a]) => s + a, 0)
const cssVar = (name: string, fallback: string) =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
/** One period of the chord: the slowest beat between its notes, which for a
12-TET chord over root f is f / 2^(1/12)-ish — so take the greatest common
period numerically instead of pretending the ratios are just. */
function chordPeriod(freqs: number[]): number {
// The tones are equal-tempered, so no exact common period exists. Use the
// lowest note's period times the smallest n that brings every other note
// close to a whole number of cycles — 16 periods is plenty for every chord
// in the book, and it is what makes the curve sit still.
const f0 = freqs[0]
let best = 1, bestErr = Infinity
for (let n = 1; n <= 16; n++) {
let err = 0
for (const f of freqs) { const c = (f / f0) * n; err += Math.abs(c - Math.round(c)) }
if (err < bestErr - 1e-9) { bestErr = err; best = n }
}
return best / f0
}
function drawTrace(
cv: HTMLCanvasElement, notes: number[],
opts: { window: "heard" | "shape"; amp: number; parts: boolean },
) {
const ctx = cv.getContext("2d")
if (!ctx) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const w = cv.clientWidth, h = cv.clientHeight
if (!w || !h) return
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
const mid = h / 2
ctx.strokeStyle = cssVar("--hair", "rgba(255,255,255,0.12)")
ctx.lineWidth = 1
ctx.beginPath(); ctx.moveTo(0, mid + 0.5); ctx.lineTo(w, mid + 0.5); ctx.stroke()
if (!notes.length || opts.amp <= 0.001) return
const freqs = notes.map(hz)
const span = opts.window === "shape" ? chordPeriod(freqs) : 0.042
// Scale for how notes actually sum, not for the worst case: dividing by the
// note count assumes every partial peaks in phase, which never happens and
// leaves the curve a flat ribbon. sqrt(n) is the honest figure, and the draw
// is clamped so the rare loud frame cannot climb out of the box.
const scale = (mid * 0.9 * opts.amp) / (Math.sqrt(notes.length) * PARTIAL_SUM)
const lim = mid * 0.95
const COLS = 480
const trace = (fs: number[]) => {
ctx.beginPath()
for (let x = 0; x <= COLS; x++) {
const u = (x / COLS) * span
let s = 0
for (const f of fs) for (const [k, a] of PARTIALS) s += a * Math.sin(2 * Math.PI * k * f * u)
const px = (x / COLS) * w
const y = mid - clamp(s * scale, -lim, lim)
x === 0 ? ctx.moveTo(px, y) : ctx.lineTo(px, y)
}
ctx.stroke()
}
// faint, the notes that make it up; bold, their sum
if (opts.parts) {
ctx.globalAlpha = 0.3; ctx.lineWidth = 1
ctx.strokeStyle = cssVar("--accent", "#ffab52")
for (const f of freqs) trace([f])
ctx.globalAlpha = 1
}
ctx.strokeStyle = cssVar("--ink", "#f3e9da")
ctx.lineWidth = 1.8
ctx.lineJoin = "round"
trace(freqs)
}
/* ---------- the wheel ----------
Twelve pitch classes on a clock, the chord's tones joined up. Two chords are
the same quality exactly when their polygons are congruent, so a shape learnt
once is learnt in all twelve keys. It also shows, at a glance, why some
chords answer to several names: a shape with rotational symmetry lands on
itself, which is dim7 (a square), aug (a triangle) and 7♯11 (a rectangle). */
function Wheel({ pcs, root, hits = [], size = 132, labels = false, dim = false, tone }: {
pcs: number[]; root: number | null; hits?: number[]; size?: number
labels?: boolean; dim?: boolean; tone?: "good" | "bad"
}) {
// The box grows to hold the ring of note names — at radius 49 in a 100-unit
// box the C at twelve o'clock had its top sheared off.
const R = 40, C = R + (labels ? 24 : 6), VB = C * 2, LR = R + 13
// 12 o'clock is C and the circle runs clockwise, the way a chord chart reads
const at = (p: number, r: number) => {
const a = (p / 12) * Math.PI * 2 - Math.PI / 2
return [C + Math.cos(a) * r, C + Math.sin(a) * r] as const
}
const ring = pcsOf(pcs).sort((a, b) => a - b)
const on = new Set(ring)
const dot = (p: number, r: number, cls: string, key: string) => {
const [x, y] = at(p, R)
return <circle key={key + p} cx={x} cy={y} r={r} className={cls} />
}
return (
<svg className={"wheel" + (dim ? " dim" : "") + (tone ? " " + tone : "")} viewBox={`0 0 ${VB} ${VB}`} width={size} height={size}
role="img" aria-label={`chord shape, ${ring.length} tones`}>
<circle cx={C} cy={C} r={R} className="rim" />
{PCS.map(p => dot(p, 1.8, "tick", "k"))}
{/* Two tones would draw as a closed pair, which is the line: no chord in
the table has fewer than three, so it never comes up. */}
{ring.length > 1 && <polygon points={ring.map(p => at(p, R).join(",")).join(" ")} className="poly" />}
{ring.map(p => dot(p, p === root ? 4.8 : 3.6, p === root ? "tone root" : "tone", "t"))}
{/* a key you pressed, marked on the clock wherever it falls */}
{pcsOf(hits).map(p => dot(p, 7.5, "hit", "h"))}
{labels && PCS.map(p => {
const [x, y] = at(p, LR)
return <text key={"l" + p} x={x} y={y} className={on.has(p) ? "wl on" : "wl"}>{ROOT_NAMES[p]}</text>
})}
</svg>
)
}
/** An option's shape, drawn on the root of the chord you actually heard — the
same root it is voiced on when you click to hear it. So all four sit on one
clock and the right one lands exactly on the big wheel's polygon. */
const optionPcs = (q: Quality, c: Chord) => pcsOf(optionNotes(q, c))
/** How wide one digit is, in ems, for the font the page actually got: `system-ui`
is a different typeface on every platform and the countdown is placed a glyph
at a time, so the advance has to be measured rather than assumed. Once, on
first use, off a canvas. */
let advEm = 0
function digitAdvance() {
if (advEm) return advEm
advEm = 0.56 // a fair guess for a bold grotesque
try {
const cx = document.createElement("canvas").getContext("2d")
if (cx) {
cx.font = `700 100px ${cssVar("--sans", "system-ui, sans-serif")}`
const w = cx.measureText("8").width / 100
if (w > 0.3 && w < 0.9) advEm = w // anything outside that is a failed measure
}
} catch { /* the guess stands */ }
return advEm
}
/* ---------- the clock, which is a candle ----------
Once the call is made the candle stops where it burned to and goes out; the
wax never springs back, because the time you took is part of the record.
Getting it right is the opposite event, so it gets the opposite picture. A
breath of air hits the wick: the flame leaps, its heart goes white, and it
settles to a burn a little taller and a little steadier than the one before.
The leap is sized by the wax still standing, which is the measurement the
candle already is. Name it in a second and it jumps; scrape in on the last of
the clock and it barely lifts. The other half of the leap is the run, which
arrives as `--run` on the board's root and is folded in by `--lift` in the
stylesheet: how well you are going scales how hard the flame answers. That is
what a miss costs, and it is why the snuff needed something worth losing.
And past five in a row the flame changes colour and stands taller: green and
half again its height, then blue at eight, then purple at ten. Both of those
move in steps, at the same three thresholds, which is the whole lesson of how
this was built. Three earlier passes ramped a size smoothly with the streak
and every one of them was invisible, because the candle is a moving target
(the wax drops it thirty pixels a round) with nothing beside it holding still,
and a few per cent per chord under a confound that size is a puzzle, not a
signal. A step is seen. A drift is not. Colour was held out at first on the
argument that the channel belonged to the clock; the argument was true and
beside the point, since what it left behind could not be seen at all. See
`.candle.run5` for what it costs the clock, which is less than it looks. */
function Candle({ frac, secs, low, out, won, streak = 0 }: {
frac: number; secs: number; low: boolean; out?: boolean; won?: boolean; streak?: number
}) {
// The box carries 20 units of headroom above a full candle, because the flame
// stands 20 above the wick and was being sheared off the top of the viewBox.
const H = 58, BASE = 96, y = BASE - H * frac
const FOOT = BASE + 11 // where the base's curve bottoms out, wherever the top is
/* The leap keeps a floor under it: naming a chord with two seconds of wax left
is still naming it, and still worth a lift you can see. */
const flare = won ? 0.35 + 0.65 * clamp01(frac) : 0
const run = runOf(streak)
/* The tier the run has reached, which is the flame's colour. Held while the
run is, not only on the chord that earned it, so a green flame is the state
of the set and not a one-second congratulation. */
const tier = streak >= 10 ? " run10" : streak >= 8 ? " run8" : streak >= 5 ? " run5" : ""
/* Embers, and the one part of the leap that is a difference in kind rather
than in degree. Nothing at all for the first right answer, one at two in a
row, nine at ten: "was that bigger than the last one" is a question an eye
is bad at, and "were there sparks" is one it cannot get wrong. */
const sparks = won ? Math.floor(run * 9) : 0
/* The number is painted on the wax, so it rides down with the wax. Two things
drive it off, and whichever comes first wins: the wax, which under about
twenty-six units of column has the digits cramped and under sixteen has
nowhere to put them at all, and the clock, which is the point of the whole
thing. Either way it climbs clear of the flame, straightens out (no cylinder
under it any more to bend to) and goes red. The clock's trigger is in
seconds rather than in wax, so the drift starts on three whichever speed the
round was set to. At 30s the wax runs out before that and takes it first, at
5s and 10s the clock does. */
const column = FOOT - y // wax standing under the number
const esc = clamp01(Math.max((22 - column) / 7, (3 - secs) / 1.4))
const lift = esc * esc * (3 - 2 * esc) // smoothstep, so it leaves and lands gently
const onWax = (y + FOOT) / 2 // centred on whatever wax is left
const numY = onWax + (48 - 7 * lift - onWax) * lift
/* Painting a number on a cylinder is not the same as bending it along a curve,
which is what running it down an arc does: that tips each glyph off vertical,
and a wrapped glyph never tips. Its uprights run parallel to the candle's
axis, and the axis projects straight up however the candle is turned. What
the wrap actually does is three things, all of them driven by how far round
the barrel a glyph has travelled: it slides towards the middle, it narrows,
and it rides up by the same foreshortening the rim already shows.
Two digits only reach about 20 degrees round a barrel this wide, so this is
a whisper and not a bow. That is the honest size of it. */
const n = Math.max(0, Math.ceil(secs))
const FS = 22 + 4 * lift // it grows a little as it breaks free
const R = 16, RIM = 4 // the barrel, and the rim's own ry
const wrapped = 1 - lift // off the wax there is no barrel left
const adv = digitAdvance() * FS
const digits = String(n).split("").map((ch, i, all) => {
const s = (i - (all.length - 1) / 2) * adv // arc from the front of the candle
const th = s / R
const c = Math.cos(th)
return {
ch,
x: 32 + s + (R * Math.sin(th) - s) * wrapped,
y: numY - RIM * (1 - c) * wrapped,
w: 1 - (1 - c) * wrapped,
}
})
// the last second dims out instead of blinking out
const fade = clamp01(secs)
/* Smoke rises continuously out of a source that does not move. The wiggle is
diffusion, so its amplitude is a function of distance travelled from the
wick — zero at the wick itself — and the pattern advects upward through a
base that stays put. Translating the whole plume, as this used to, detaches
it from the wick and reads as a puff being thrown rather than smoke. */
const [t, setT] = useState(0)
useEffect(() => {
if (!out) { setT(0); return }
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return
let raf = 0
const t0 = performance.now()
const tick = () => {
const age = (performance.now() - t0) / 1000
setT(age)
if (age < 20) raf = requestAnimationFrame(tick) // by then it has gone still
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [out])
const PLUME = 54
const wisp = (phase: number, freq: number, lean: number) => {
const calm = Math.exp(-t / 7) // the air settles
let d = `M32 ${(y - 3).toFixed(2)}`
for (let i = 1; i <= 26; i++) {
const f = i / 26 // fraction of the way up
const h = f * PLUME
const spread = Math.pow(f, 1.6) // diffusion widens with distance
const x = 32 + lean * spread * 7
+ 6.5 * spread * calm * Math.sin(freq * h - t * 2.6 + phase)
d += ` L${x.toFixed(2)} ${(y - 3 - h).toFixed(2)}`
}
return d
}
return (
// wide enough for two digits of wax: at 20 units it could not hold "25"
<svg className={"candle" + (low ? " low" : "") + (out ? " out" : "") + (won ? " won" : "") + tier}
viewBox="0 0 64 118" role="img"
style={{ "--flare": flare } as React.CSSProperties}
aria-label={out ? "candle out" : `${n} seconds left`}>
<defs>
<linearGradient id="waxfill" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#bfb197" />
<stop offset="26%" stopColor="#f2e7cd" />
<stop offset="58%" stopColor="#e6d9ba" />
<stop offset="100%" stopColor="#a89a80" />
</linearGradient>
{/* thins out with height, so the plume dissipates instead of stopping */}
<linearGradient id="smokefade" gradientUnits="userSpaceOnUse"
x1="32" y1={y - 3} x2="32" y2={y - 3 - PLUME}>
<stop offset="0%" stopColor="#d2c9bc" stopOpacity="0.92" />
<stop offset="30%" stopColor="#c6bdb0" stopOpacity="0.62" />
<stop offset="68%" stopColor="#b6ada1" stopOpacity="0.27" />
<stop offset="100%" stopColor="#b6ada1" stopOpacity="0" />
</linearGradient>
</defs>
{/* A cylinder seen side-on: straight flanks lit from the left, an ellipse
capping the foot the way one caps the top. No holder — every version of
one read as a plate stuck on behind it. */}
<path className="wax" d={`M16 ${y} v${H * frac + 7} a16 4 0 0 0 32 0 v${-(H * frac + 7)} z`} />
<ellipse cx="32" cy={y} rx="16" ry="4" className="waxtop" />
<ellipse cx="32" cy={y} rx="16" ry="4" className="waxlip" />
{out ? (
<>
{/* the wick stays out of the blurred group: it is the one thing here
with an edge */}
<path className="wick" d={`M32 ${y - 4} L32 ${y}`} />
<g className="smoke">
<path className="wisp" d={wisp(0, 0.34, -0.55)} />
<path className="wisp two" d={wisp(2.1, 0.44, 0.75)} />
<path className="wisp three" d={wisp(4.2, 0.26, 0.15)} />
</g>
</>
) : (
/* Three nested groups, because three transforms have to compose and an
element gets one: the leap, the flicker inside it, and the tier's
standing height inside that. Fold any pair together and the leap's
keyframes overwrite whatever shared its `transform`. All three turn
about the wick, which is where a flame is anchored. */
<g className="flareg" style={{ transformOrigin: `32px ${y}px` }}>
<g className="flamewrap" style={{ transformOrigin: `32px ${y}px` }}>
{/* Outside `.stand` on purpose. The halo leaps and flickers with the
flame but does not take the tier's height: stretched by that as
well it turns into a smudge that swallows the shape it is meant to
sit behind, and it is also the piece that would spill furthest
over the top of a box with 18px of room above it. */}
<ellipse cx="32" cy={y - 11} rx="8.5" ry="14" className="halo" />
<g className="stand" style={{ transformOrigin: `32px ${y}px` }}>
<path d={`M32 ${y - 20} C 36.5 ${y - 12}, 36.5 ${y - 3}, 32 ${y - 2} C 27.5 ${y - 3}, 27.5 ${y - 12}, 32 ${y - 20} Z`} className="flame" />
{/* the same cone at a bit over half height, dark until the leap
lifts it: a flame that surges brightens from the inside out */}
<path d={`M32 ${y - 12.2} C 34.6 ${y - 7.6}, 34.6 ${y - 2.6}, 32 ${y - 2} C 29.4 ${y - 2.6}, 29.4 ${y - 7.6}, 32 ${y - 12.2} Z`} className="core" />
</g>
</g>
</g>
)}
{/* Outside the flame's groups: an ember that has left the wick is no longer
part of the flame and must not be scaled or flickered by it. The spread
is a fixed hash of the index rather than a random, so a re-render during
the reveal cannot re-scatter them mid-flight. */}
{sparks > 0 && (
<g className="sparks">
{Array.from({ length: sparks }, (_, i) => (
<circle key={i} className="spark" cx={32} cy={y - 7} r={0.75 + (i % 3) * 0.3}
style={{
"--dx": ((i * 37) % 11) - 5,
"--dy": -24 - ((i * 53) % 19),
animationDelay: `${0.05 + i * 0.05}s`,
} as React.CSSProperties} />
))}
</g>
)}
{n > 0 && !out && (
<g style={{ opacity: fade }}>
<g className={"secs" + (lift > 0.1 ? " free" : "")} style={{ fontSize: FS }}>
{digits.map((d, i) => (
// scaled about the glyph's own centre, which is what `x`/`y` at the
// origin plus a translate buys us
<text key={i} x="0" y="0"
transform={`translate(${d.x.toFixed(2)} ${d.y.toFixed(2)}) scale(${d.w.toFixed(3)} 1)`}>
{d.ch}
</text>
))}
</g>
</g>
)}
</svg>
)
}
const isBlack = (m: number) => [1, 3, 6, 8, 10].includes(m % 12)
/** Every button that stays where it is when you press it springs on the press.
A CSS animation restarts when its computed name changes and at no other time,
so pressing the same button twice would re-add a class it already had and
play nothing: the class alternates between two identical sets of keyframes
instead. That trick was written out by hand at each button that needed it,
which is why it kept being left off the ones that did not have it yet. */
/** Drop the focus ring as the button fires: these are pressed, not tabbed
through, and a ring left standing on the last one read as a selection. */
const tap = (fn: () => void) => (e: React.MouseEvent<HTMLElement>) => { e.currentTarget.blur(); fn() }
function Tap({ className = "", onClick, ...rest }: React.ComponentProps<"button">) {
const [n, setN] = useState(0)
return (
<button {...rest}
className={className + (n === 0 ? "" : n % 2 ? " tapA" : " tapB")}
onClick={e => { setN(v => v + 1); onClick?.(e) }} />
)
}
/* ---------- the marks ----------
One mark per action, and the mark is the whole button. Replay went through a
circular arrow, which reads as "reset the whole thing", and then the repeat
sign, which is the correct notation for it and means nothing to anyone who has
not been taught it. A note says the one thing it needs to: sound. The mark for
moving on is a head against a barline, which is the notation reading and the
skip-forward reading at once, so it lands either way. The end of the set is
the one place with nothing that lands: the final double bar is meaningless to
anyone it has not been explained to, so the tenth call is answered with the
word Results instead. */
const Play = () => (
<svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
{/* a triangle's weight is a third of the way back from its point, so it is
set right of centre: box-centred, it reads as leaning left */}
<path d="M6.2 2.5 L22.7 12 L6.2 21.5 Z" />
</svg>
)
const Note = () => (
<svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
<ellipse cx="8.2" cy="18" rx="4.6" ry="3.4" transform="rotate(-22 8.2 18)" />
<rect x="11.1" y="3.2" width="1.9" height="14.9" />
<path d="M13 3.2 C 17.2 5.4 19.6 7.4 19.6 10.6 C 19.6 12.4 18.7 13.7 17.2 14.6
C 17.9 12.6 17.4 10.6 15.4 9 C 14.6 8.4 13.8 7.9 13 7.4 Z" />
</svg>
)
const Barline = () => (
<svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 2.5 L14 12 L3 21.5 Z" />
<rect x="16.6" y="2.5" width="3.6" height="19" />
</svg>
)
/* and three that are not notation, because nothing in notation means "settings",
"take a picture of this" or "that worked" */
const Sliders = () => (
<svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
<rect x="2.5" y="4.6" width="2.2" height="1.8" rx="0.9" />
<rect x="11.2" y="4.6" width="10.3" height="1.8" rx="0.9" />
<circle cx="8" cy="5.5" r="2.9" />
<rect x="2.5" y="11.1" width="10.2" height="1.8" rx="0.9" />
<rect x="19.2" y="11.1" width="2.3" height="1.8" rx="0.9" />
<circle cx="16" cy="12" r="2.9" />
<rect x="2.5" y="17.6" width="4.6" height="1.8" rx="0.9" />
<rect x="13.7" y="17.6" width="7.8" height="1.8" rx="0.9" />
<circle cx="10.5" cy="18.5" r="2.9" />
</svg>
)
const Camera = () => (
// body, viewfinder and lens in one path, so evenodd punches the lens out
<svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
<path fillRule="evenodd" d={
"M8 3.6 h6 a1.2 1.2 0 0 1 1.2 1.2 v1.7 h-8.4 v-1.7 a1.2 1.2 0 0 1 1.2 -1.2 Z"
+ " M5 6.5 h14 a2.5 2.5 0 0 1 2.5 2.5 v8.5 a2.5 2.5 0 0 1 -2.5 2.5"
+ " h-14 a2.5 2.5 0 0 1 -2.5 -2.5 v-8.5 a2.5 2.5 0 0 1 2.5 -2.5 Z"
+ " M7.6 13.2 A 4.4 4.4 0 1 1 16.4 13.2 A 4.4 4.4 0 1 1 7.6 13.2 Z"
} />
</svg>
)
const Tick = () => (
<svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 12.6 L9.6 18.2 L20 6.4" fill="none" stroke="currentColor"
strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const Cross = () => (
<svg className="mark" viewBox="0 0 24 24" aria-hidden="true">
<path d="M5.8 5.8 L18.2 18.2 M18.2 5.8 L5.8 18.2" fill="none" stroke="currentColor"
strokeWidth="3" strokeLinecap="round" />
</svg>
)
/* Dimmed and dead are the same state, so there is one flag for them: no
`onPlay` is what makes it dim. A dimmed keyboard you can still press would be
a keyboard that says it is out of the question and then answers anyway. */
function Keyboard({ lit, root, pressed = [], onPlay }: {
lit: number[]; root: number | null; pressed?: number[]; onPlay?: (m: number) => void
}) {
const W = 24, H = 78, BW = 14, BH = 48
const whites: number[] = [], blacks: number[] = []
for (let m = KB_LO; m <= KB_HI; m++) (isBlack(m) ? blacks : whites).push(m)
const wx = (m: number) => whites.indexOf(m) * W
const litSet = new Set(lit), pressedSet = new Set(pressed)
const cls = (base: string, m: number) =>
base + (litSet.has(m) ? " on" : "") + (pressedSet.has(m) ? " press" : "") + (onPlay ? " live" : "")
const key = (m: number, geom: { x: number; y: number; width: number; height: number }, base: string) => (
<rect key={m} {...geom} rx={2} className={cls(base, m)} role={onPlay ? "button" : undefined} aria-label={noteName(m)}
onPointerDown={onPlay ? e => { e.preventDefault(); onPlay(m) } : undefined}>
<title>{noteName(m)}</title>
</rect>
)
return (
<svg className={"kb" + (onPlay ? "" : " dim")} viewBox={`0 0 ${whites.length * W} ${H}`}
role="group" aria-label="keyboard">
{whites.map(m => key(m, { x: wx(m), y: 0, width: W, height: H }, "wk"))}
{blacks.map(m => key(m, { x: wx(m - 1) + W - BW / 2, y: 0, width: BW, height: BH }, "bk"))}
{whites.filter(m => m % 12 === 0).map(m => (
<text key={"l" + m} x={wx(m) + W / 2} y={H - 6} className="kl" pointerEvents="none">C{m / 12 - 1}</text>
))}
{root !== null && lit.filter(m => m % 12 === root).map(m => (
<circle key={"r" + m} r={3.4} className="rootdot" pointerEvents="none"
cx={isBlack(m) ? wx(m - 1) + W : wx(m) + W / 2} cy={isBlack(m) ? BH - 10 : H - 21} />
))}
</svg>
)
}
function Seg<T extends string | number | boolean>({ value, onChange, items, label }: {
value: T; onChange: (v: T) => void; items: { v: T; label: string }[]; label: string
}) {
return (
<div className="seg" role="group" aria-label={label}>
{items.map(it => (
<button key={String(it.v)} className={it.v === value ? "on" : ""} aria-pressed={it.v === value}
onClick={tap(() => onChange(it.v))}>{it.label}</button>
))}
</div>
)
}
const fmtSec = (ms: number) => (ms / 1000).toFixed(1) + " s"
// ---------- game state ----------
// The page opens on "ready": the board is dealt and the first chord is already
// chosen, just not yet sounded. Nothing to set up before you can play.
type Phase = "ready" | "round" | "reveal" | "results"
type RoundRec = { chord: Chord; options: Quality[]; answer: number; picked: number | null; correct: boolean; ms: number }
type Summary = { correct: number; avgMs: number | null }
type Game = {
phase: Phase; settings: Settings
round: number; right: number; streak: number
chord: Chord; options: Quality[]; answer: number
picked: number | null; correct: boolean | null
history: RoundRec[]; last: Summary | null
t0: number; dur: number
}
const STORE = "the-coltrainer"
const STORE_WAS = "chord-shapes" // settings saved under the old name
function loadSettings(): Settings {
try {
const s = JSON.parse(localStorage.getItem(STORE) || localStorage.getItem(STORE_WAS) || "null")
if (s) {
const out: Settings = { ...DEFAULTS, ...s }
// a pool saved before the ladder was regrouped names nothing now, and
// poolOf would walk straight off the end of the list looking for it
if (!POOLS.some(p => p.id === out.pool)) out.pool = DEFAULTS.pool
if (!SPEEDS.some(x => x.v === out.clock)) out.clock = DEFAULTS.clock
return out
}
} catch { /* nothing saved yet */ }
return { ...DEFAULTS }
}
function saveSettings(s: Settings) { try { localStorage.setItem(STORE, JSON.stringify(s)) } catch {} }
/** A chord and its four names, chosen but not yet played. */
function deal(s: Settings, prev: Chord | null) {
const chord = makeChord(s, prev)
const { options, answer } = makeOptions(chord, s)
return { chord, options, answer }
}
const newGame = (): Game => {
const settings = loadSettings()
return {
phase: "ready", settings,
round: 0, right: 0, streak: 0,
...deal(settings, null), picked: null, correct: null,
history: [], last: null, t0: 0, dur: 1,
}
}
function summarize(h: RoundRec[]): Summary {
const right = h.filter(r => r.correct)
return {
correct: right.length,
avgMs: right.length ? right.reduce((a, r) => a + r.ms, 0) / right.length : null,
}
}
/** What the cell under a chord says: how long you took, or what you called it.
Shared by the card on screen and the card in the image, so the two cannot
drift apart. */
const saidOf = (r: RoundRec) =>
r.correct ? `${(r.ms / 1000).toFixed(1)}s`
: r.picked === null ? "timed out"
: lower(r.options[r.picked].name)
/** The settings a set was played under, as one line. Root position is the
default and says nothing, so it is left out: only a departure from it is
worth a word on the card. The label is read off the segmented control
rather than written again here, so the two cannot say it differently. */
const setupLine = (s: Settings) => [
POOLS.find(p => p.id === s.pool)!.label,
...(s.inversions ? [VOICINGS.find(x => x.v)!.label] : []),
`${s.clock}s`,
].join(" · ")
/* ---------- the scorecard as an image ----------
The card on screen is already built to be screenshotted; this draws the same
card again, at a fixed size, as a file you can hand over. Not a rasteriser
pointed at the DOM: the wheels are SVG coloured entirely from the stylesheet,
which none of those carries across, and the page has to fold the grid to two
columns on a phone — where an image, answering to no viewport, keeps the five
across that let ten chords read as one set.
Every measurement lives in `shotLayout`, the height included: it falls out of
the same running cursor the drawing then follows, so the two cannot disagree
about where anything is. */
type ShotCell = { pcs: number[]; root: number; sym: string; said: string; correct: boolean }
type Shot = { correct: number; setup: string; foot: string; cells: ShotCell[] }
const SHOT_LINK = "typebulb.com/u/antypica/the-coltrainer"
const SHOT_FILE = "the-coltrainer.png"
/** `foot` says whether there is an average to print: with nothing right there
is nothing to say, and the row is left out rather than left empty. */
function shotLayout(foot: boolean) {
const w = 1120, m = 18, pad = 46, gap = 20, wheel = 130
const x0 = m + pad
const col = (w - 2 * x0 - 4 * gap) / 5
// a shape, its name, and what you said — the last with room for two lines,
// because "dominant 7th ♭9♯11" does not fit a fifth of the card on one
const rowH = wheel + 14 + 30 + 4 + 44
let y = m + 46
const brand = y; y += 46
const tally = y; y += 112
const setup = y; y += 36 + 26
const grid = y; y += rowH * 2 + 24
// modest, because the row above it has already reserved a second line of
// "what you said" that most sets never use
y += 14
const rule = y; y += 26
const footAt = y; y += foot ? 34 : 0
const link = y; y += 36
return { w, h: y + m + 22, m, x0, gap, col, wheel, rowH, brand, tally, setup, grid, rule, foot: footAt, link }
}
/** The wheel again, in canvas: the same 92-unit box and radius-40 ring the SVG
uses, so a shape on the image is the shape that was on the page. */
function shotWheel(
ctx: CanvasRenderingContext2D, cx: number, cy: number, size: number,
pcs: number[], root: number, good: boolean,
) {
const u = size / 92, R = 40 * u
const at = (pc: number, r: number) => {
const a = (pc / 12) * Math.PI * 2 - Math.PI / 2
return [cx + Math.cos(a) * r, cy + Math.sin(a) * r] as const
}
const ring = pcsOf(pcs).sort((a, b) => a - b)
const line = good ? cssVar("--good", "#86d59a") : cssVar("--bad", "#ff8f79")
const soft = good ? cssVar("--good-soft", "rgba(134, 213, 154, 0.13)")
: cssVar("--bad-soft", "rgba(255, 143, 121, 0.13)")
const hair = cssVar("--hair", "rgba(255, 224, 190, 0.16)")
const dot = (x: number, y: number, r: number) => { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2) }
dot(cx, cy, R); ctx.strokeStyle = hair; ctx.lineWidth = u; ctx.stroke()
ctx.fillStyle = hair
for (const pc of PCS) { const [x, y] = at(pc, R); dot(x, y, 1.8 * u); ctx.fill() }
if (ring.length > 1) {
ctx.beginPath()
ring.forEach((pc, i) => { const [x, y] = at(pc, R); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y) })
if (ring.length > 2) { ctx.closePath(); ctx.fillStyle = soft; ctx.fill() }
ctx.strokeStyle = line; ctx.lineWidth = 1.6 * u; ctx.lineJoin = "round"; ctx.stroke()
}
for (const pc of ring) {
const [x, y] = at(pc, R)
dot(x, y, (pc === root ? 4.8 : 3.6) * u)
if (pc === root) {
ctx.fillStyle = cssVar("--ink", "#f2e9dc"); ctx.fill()
ctx.strokeStyle = line; ctx.lineWidth = 1.4 * u; ctx.stroke()
} else { ctx.fillStyle = line; ctx.fill() }
}
}
/** Centred text that stays inside its column: it wraps as far as it is allowed
to, and only a word too wide to wrap gives up a tail to an ellipsis. */
function shotText(
ctx: CanvasRenderingContext2D, s: string, cx: number, top: number,
w: number, lh: number, max: number,
) {
const lines: string[] = []
let cur = ""
for (const word of s.split(" ")) {
const next = cur ? cur + " " + word : word
if (cur && ctx.measureText(next).width > w) { lines.push(cur); cur = word } else cur = next
if (lines.length === max) { cur = ""; break }
}
if (cur) lines.push(cur)
lines.forEach((l, i) => {
while (l.length > 1 && ctx.measureText(l).width > w) l = l.replace(/…$/, "").slice(0, -1) + "…"
ctx.fillText(l, cx, top + i * lh)
})
}
function drawShot(cv: HTMLCanvasElement, shot: Shot) {
const ctx = cv.getContext("2d")
if (!ctx) return
const L = shotLayout(!!shot.foot)
// drawn at 2×, so it holds up full-screen on the phone it gets sent to
const S = 2
cv.width = L.w * S; cv.height = L.h * S
ctx.setTransform(S, 0, 0, S, 0, 0)
const muted = cssVar("--muted", "#8b8073"), accent = cssVar("--accent", "#e7a765")
const hair = cssVar("--hair", "rgba(255, 224, 190, 0.16)"), bad = cssVar("--bad", "#ff8f79")
const inkHi = cssVar("--ink-hi", "#fff3e0"), inkWarm = cssVar("--ink-warm", "#ffe8c4")
const sans = cssVar("--sans", "system-ui, sans-serif")
const serif = cssVar("--serif", "Georgia, serif")
const mid = L.w / 2
// the room, and the card sitting in it — the same two surfaces as the page
ctx.fillStyle = cssVar("--bg", "#0e0d0c")
ctx.fillRect(0, 0, L.w, L.h)
ctx.beginPath()
ctx.roundRect(L.m + 0.5, L.m + 0.5, L.w - 2 * L.m - 1, L.h - 2 * L.m - 1, 18)
ctx.fillStyle = cssVar("--pane", "#151312"); ctx.fill()
ctx.strokeStyle = hair; ctx.lineWidth = 1; ctx.stroke()
ctx.textAlign = "center"; ctx.textBaseline = "top"
ctx.font = `italic 700 39px ${serif}`; ctx.fillStyle = accent
ctx.fillText("The Coltrainer", mid, L.brand)
// the score, big, with "of 10" standing on its baseline the way the page has it
const big = `700 100px ${sans}`, of = `500 30px ${sans}`
ctx.textAlign = "left"; ctx.textBaseline = "alphabetic"
ctx.font = big; const bw = ctx.measureText(String(shot.correct)).width
ctx.font = of; const ow = ctx.measureText(`of ${ROUNDS}`).width
// to scale with the page's 5px at 58px: the canvas has no tabular figures to
// pad the number, so it wants a touch more than the ratio alone would give
const gap = 10
const x = mid - (bw + gap + ow) / 2
ctx.font = big; ctx.fillStyle = inkHi; ctx.fillText(String(shot.correct), x, L.tally + 100)
ctx.font = of; ctx.fillStyle = muted; ctx.fillText(`of ${ROUNDS}`, x + bw + gap, L.tally + 100)
ctx.textAlign = "center"; ctx.textBaseline = "top"
ctx.font = `400 24px ${sans}`; ctx.fillStyle = muted
ctx.fillText(shot.setup, mid, L.setup)
shot.cells.forEach((c, i) => {
const cx = L.x0 + (i % 5) * (L.col + L.gap) + L.col / 2
const top = L.grid + Math.floor(i / 5) * (L.rowH + 24)
shotWheel(ctx, cx, top + L.wheel / 2, L.wheel, c.pcs, c.root, c.correct)
ctx.font = `700 26px ${serif}`; ctx.fillStyle = inkWarm
shotText(ctx, c.sym, cx, top + L.wheel + 14, L.col, 30, 1)
ctx.font = `400 20px ${sans}`; ctx.fillStyle = c.correct ? muted : bad
shotText(ctx, c.said, cx, top + L.wheel + 48, L.col, 24, 2)
})
ctx.beginPath()
ctx.moveTo(L.x0, L.rule + 0.5); ctx.lineTo(L.w - L.x0, L.rule + 0.5)
ctx.strokeStyle = hair; ctx.lineWidth = 1; ctx.stroke()
if (shot.foot) {
ctx.font = `400 24px ${sans}`; ctx.fillStyle = muted
ctx.fillText(shot.foot, mid, L.foot)
}
// whoever sees this has no idea what it is, so the card says where it came from
ctx.font = `600 24px ${sans}`; ctx.fillStyle = accent
ctx.fillText(SHOT_LINK, mid, L.link)
}
const shotOf = (g: Game): Shot => ({
correct: g.last!.correct,
setup: setupLine(g.settings),
foot: g.last!.avgMs !== null ? `${fmtSec(g.last!.avgMs)} average when right` : "",
cells: g.history.map(r => ({
pcs: pcsOf(r.chord.notes),
root: r.chord.rootPc,
sym: symbolOf(r.chord),
said: saidOf(r),
correct: r.correct,
})),
})
// Every combination of settings, many rounds each: four distinct options, the
// answer among them exactly once, no second right answer hiding as a distractor,
// every note on the keyboard, every spelled name the pitch it claims.
function selftest() {
const fails: string[] = []
let n = 0
// no two kinds in one pool may be the same notes over the same root — that would
// be one chord under two names, unanswerable even in root position
for (const p of POOLS) {
const qs = poolOf(p.id)
for (let i = 0; i < qs.length; i++) for (let j = i + 1; j < qs.length; j++)
if (pcMask(0, qs[i].ivs) === pcMask(0, qs[j].ivs)) fails.push(`${p.id}: ${symOf(qs[i])} and ${symOf(qs[j])} are the same notes`)
}
if (new Set(QUALITIES.map(q => q.id)).size !== QUALITIES.length) fails.push("duplicate quality id")
if (new Set(QUALITIES.map(symOf)).size !== QUALITIES.length) fails.push("duplicate chord symbol")
for (const pool of POOLS) for (const inversions of [false, true]) {
const s: Settings = { ...DEFAULTS, pool: pool.id, inversions }
let prev: Chord | null = null
for (let i = 0; i < 150; i++) {
const { chord: c, options, answer } = deal(s, prev); prev = c; n++
const tag = `${pool.id}/${inversions ? "inv" : "root"} ${symbolOf(c)}`
if (options.length !== 4) fails.push(`${tag}: ${options.length} options`)
if (new Set(options.map(o => o.id)).size !== options.length) fails.push(`${tag}: duplicate option`)
// …and no two of them may be one set of notes under two names
const sets = options.map(o => pcMask(0, o.ivs))
if (new Set(sets).size !== sets.length) fails.push(`${tag}: two options are the same notes`)
const a = options[answer]
if (!a || a.id !== c.q.id) fails.push(`${tag}: answer mismatch`)
options.forEach((o, i) => { if (i !== answer && clashes(o, c, s)) fails.push(`${tag}: second right answer ${o.name}`) })
if (c.notes.some(x => x < KB_LO || x > KB_HI)) fails.push(`${tag}: note off keyboard`)
spell(c).forEach((nm, i) => {
const pc = (c.rootPc + c.q.ivs[i]) % 12
if (nameToPc(nm) !== pc) fails.push(`${tag}: spelled ${nm} for pc ${pc}`)
})
if (nameToPc(c.rootName) !== c.rootPc) fails.push(`${tag}: root spelled ${c.rootName}`)
}
}
return { rounds: n, failures: fails.slice(0, 20), verdict: fails.length ? "FAIL" : "ok" }
}
/* ---------- the game ---------- */
/** The trace, wired to the sound: it strikes with the chord, decays with it,
and then holds — nothing on this page moves while you are thinking. */
function Trace({ notes, strike, mode, parts }: {
notes: number[]; strike: number; mode: "heard" | "shape"; parts: boolean
}) {
const ref = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const cv = ref.current
if (!cv) return
let raf = 0
const t0 = performance.now()
// The shape curve used to replace the decaying one in a single frame: a wave
// in motion, then a different wave standing still. It springs in instead —
// the same damped oscillation the reveal's keyframes use, written as a
// function: past full height, back under, to rest. `drawTrace` clamps the
// draw, so the overshoot cannot climb out of the box.
const SWELL = 0.55
const spring = (x: number) => x >= 1 ? 1 : 1 - Math.exp(-7 * x) * Math.cos(8.5 * x)
const tick = () => {
const age = (performance.now() - t0) / 1000
// The synth's own envelope, so the picture dies at the rate the sound does
// — but it settles to a legible line rather than a flat one, because it
// has to stay readable for as long as you are still deciding.
const amp = mode === "shape"
? spring(age / SWELL)
: Math.max(0.45, Math.exp(-age / 1.5))
drawTrace(cv, notes, { window: mode, amp, parts })
// Stop when the picture stops changing, not later. `amp` is the only thing
// that varies between frames, and the decay reaches its 0.45 floor at
// 1.5·ln(1/0.45) ≈ 1.2s: every frame after that redraws the same image.
raf = age < (mode === "shape" ? SWELL : 1.2) ? requestAnimationFrame(tick) : 0
}
tick()
// Redraw at the current age; do NOT re-enter tick, which would schedule a
// second rAF chain alongside the first. `observe` always fires once on
// attach, so that is exactly what used to happen: every frame drawn twice,
// and cleanup cancelling only the later of the two handles.
const ro = new ResizeObserver(() => { if (!raf) tick() })
ro.observe(cv)
return () => { cancelAnimationFrame(raf); ro.disconnect() }
}, [notes, strike, mode, parts])
return <canvas ref={ref} className="trace" aria-label="the chord as a waveform" role="img" />
}
function App() {
// useRef evaluates its argument on every render and keeps only the first, so a
// bare useRef(newGame()) re-reads localStorage and deals a whole chord, only to
// throw it away, on every frame the clock renders. useState's initialiser is
// the one that runs once; the box it holds is the ref.
const [G] = useState(() => ({ current: newGame() }))
const [, force] = useState(0)
const bump = () => force(n => n + 1)
const [left, setLeft] = useState(1)
const [pressed, setPressed] = useState<number[]>([])
const [strike, setStrike] = useState(0)
// Bumped on every audition, and only its parity is used. A CSS animation
// restarts when its computed name changes and at no other time, so hearing the
// same option twice — or hearing the right answer, which already carries the
// reveal's own animation — would otherwise re-add a class the element already
// had and play nothing. Alternating between two identical keyframes forces the
// name to change on every click.
const [pulse, setPulse] = useState(0)
/* The reveal's spring is a one-shot event, not a standing style. Declared on
.good/.bad it re-fires whenever an audition's own animation is taken back
off the element — the computed name flips from the pulse to the reveal, and
the browser reads that as a fresh animation — so clicking away from the
right answer sprang it a second time. This carries it for exactly as long as
it runs, and an audition takes it off early so the two can never trade. */
const [sprung, setSprung] = useState(false)
const sprungT = useRef(0)
const springOnce = () => {
window.clearTimeout(sprungT.current)
setSprung(true)
sprungT.current = window.setTimeout(() => setSprung(false), 620)
}
const springOff = () => { window.clearTimeout(sprungT.current); setSprung(false) }
const [audition, setAudition] = useState<{ i: number; notes: number[] } | null>(null)
const [showSetup, setShowSetup] = useState(false)
/* Until the first chord has sounded, Start breathes a ring. The page arrives
silent and still, with a stopped candle beside four unlit names, so nothing
in the picture says that pressing something is what starts it: 12-TET's play
button wears the same nudge for the same reason. Cleared for good on the
first press rather than per set, since coming back through the setup panel
is not first contact. */
const [everBegan, setEverBegan] = useState(false)
const raf = useRef(0)
const g = G.current
const s = g.settings
/** Light a key, then let it go. One occurrence, not all of them: an arpeggio
can have the same pitch class lit twice over and the first release must not
unlight the second. */
function flash(m: number, ms: number) {
setPressed(p => [...p, m])
window.setTimeout(() => setPressed(p => {
const i = p.indexOf(m)
return i < 0 ? p : [...p.slice(0, i), ...p.slice(i + 1)]
}), ms)
}
function press(m: number) {
playNote(m)
flash(m, 420)
}
// The trace strikes when the chord does, not when the click did: on the first
// sound of the session those can be a warm-up apart.
const sound = (notes: number[]) => playChord(notes, () => setStrike(n => n + 1))
/** Spell it out loud: each note in turn, lighting its key and its point on the
clock as it arrives. */
function arpeggio(notes: number[]) {
playArpeggio(notes, () =>
notes.forEach((m, i) => window.setTimeout(() => flash(m, 460), i * ARP_GAP * 1000)))
}
const stopClock = () => cancelAnimationFrame(raf.current)
function startClock(sec: number) {
stopClock()
g.t0 = performance.now(); g.dur = sec * 1000
const tick = () => {
const rem = Math.max(0, g.t0 + g.dur - performance.now())
setLeft(rem / g.dur)
if (rem <= 0) resolve(null)
else raf.current = requestAnimationFrame(tick)
}
setLeft(1)
raf.current = requestAnimationFrame(tick)
}
function setS(patch: Partial<Settings>) {
g.settings = { ...g.settings, ...patch }
saveSettings(g.settings)
// Changing the rules ends the set. Half a score under one pool and half
// under another is not a score of anything, and a chord already on the
// stand was dealt under settings that no longer hold.
// The panel stays as it is: these changes are made from inside it.
toReady()
}
/** The silent board the game starts from: no score, a fresh chord dealt but
not sounded, the clock stopped and full. Every route back to the start goes
through here, so the two that used to assemble it by hand cannot drift
apart again — one of them was leaving the candle wherever it stood. */
function toReady() {
stopClock()
resetSet()
Object.assign(g, deal(g.settings, null), { phase: "ready" })
setAudition(null)
setLeft(1)
bump()
}
/** Play whatever chord is on the stand and start its clock. */
function play(chord: Chord) {
g.round += 1
Object.assign(g, { phase: "round", picked: null, correct: null })
setAudition(null)
setShowSetup(false)
bump()
sound(chord.notes)
startClock(g.settings.clock)
}
/** Deal the next chord and play it in one move — Next never waits. */
function dealAndPlay() {
const d = deal(g.settings, g.chord)
Object.assign(g, d)
play(d.chord)
}
/** The opening move: the chord is already dealt, so this only sounds it. */
function begin() {
if (g.phase !== "ready") return
setEverBegan(true)
play(g.chord)
}
/** Wipe the set. Every route back to the start has to go through here — the
results screen once cleared the score but left `history` standing, so the
next set appended to the last one and summarize reported "15 of 10". */
function resetSet() {
Object.assign(g, {
round: 0, right: 0, streak: 0,
history: [], last: null, picked: null, correct: null,
})
}
function again() {
resetSet()
dealAndPlay()
}
/** Back to a dealt-but-silent board, with the setup panel open. */
const reconfigure = () => { toReady(); setShowSetup(true) }
function resolve(i: number | null) {
if (g.phase !== "round") return
stopClock()
const ms = Math.min(g.dur, performance.now() - g.t0)
const correct = i !== null && i === g.answer
g.streak = correct ? g.streak + 1 : 0
if (correct) g.right += 1
Object.assign(g, { picked: i, correct, phase: "reveal" })
g.history.push({ chord: g.chord, options: g.options, answer: g.answer, picked: i, correct, ms })
setLeft(1 - ms / g.dur)
bump()
springOnce()
cue(correct)
if (!correct) setTimeout(() => sound(g.chord.notes), 450)
}
function auditionOpt(i: number) {
const q = g.options[i]
if (!q) return
const notes = optionNotes(q, g.chord)
sound(notes)
// It stays put. After the reveal you are examining, not being quizzed, and
// a picture that snaps back to the answer after two seconds is no use for
// comparing one chord against another. Play the right answer, or hit again,
// to come back to what was actually sounded.
setAudition({ i, notes })
setPulse(p => p + 1)
springOff()
}
const answer = (i: number) => {
if (i >= g.options.length) return
if (g.phase === "round") resolve(i)
else if (g.phase === "reveal") auditionOpt(i)
}
function next() {
if (g.phase !== "reveal") return
setAudition(null)
if (g.round >= ROUNDS) { g.last = summarize(g.history); g.phase = "results"; bump() }
else dealAndPlay()
}
const replay = () => {
if (g.phase !== "round" && g.phase !== "reveal") return
setAudition(null) // back to the chord that was played
sound(g.chord.notes)
}
const api = useRef({ begin, answer, next, replay, again })
api.current = { begin, answer, next, replay, again }
// The probe handler is registered once, so anything it reports that lives in
// React state has to reach it through a ref refreshed on every render.
const live = useRef({ audition, showSetup, pulse })
live.current = { audition, showSetup, pulse }
// Build the audio graph up front, suspended, so the first gesture has only to
// resume it rather than construct it as well — and take that gesture as early
// as the pointer going down, so the device is already warming while the click
// that wants a chord is still being made.
useEffect(() => {
audio()
const go = () => {
unlock()
window.removeEventListener("pointerdown", go, true)
window.removeEventListener("keydown", go, true)
}
window.addEventListener("pointerdown", go, true)
window.addEventListener("keydown", go, true)
return () => {
window.removeEventListener("pointerdown", go, true)
window.removeEventListener("keydown", go, true)
}
}, [])
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey || e.altKey) return
const a = api.current
if (/^[1-4]$/.test(e.key)) { a.answer(+e.key - 1); e.preventDefault(); return }
// space hears it again, return moves the game on. The guard is for a
// button that somehow still holds focus: space is its own click.
if (e.key === " ") {
if ((e.target as HTMLElement | null)?.tagName === "BUTTON") return
e.preventDefault()
a.replay()
return
}
if (e.key === "Enter") {
if ((e.target as HTMLElement | null)?.tagName === "BUTTON") return
e.preventDefault()
const p = G.current.phase
if (p === "ready") a.begin()
else if (p === "reveal") a.next()
else if (p === "results") a.again()
}
}
window.addEventListener("keydown", h)
return () => { window.removeEventListener("keydown", h); cancelAnimationFrame(raf.current) }
}, [])
// terminal probes: `selftest`, `state`, `audio`, `start`, `next`, `{"answer":2}`,
// `{"set":{"pool":"jazz"}}`, `{"sample":{"pool":"jazz","n":8}}`
useEffect(() => tb.onMessage((m: unknown) => {
const gg = G.current
if (m === "selftest") return selftest()
// What the audio device cost to open, and whether we could see it open at all
if (m === "audio") return {
state: ac?.state ?? null, warm, live: warmup.live, warmupMs: warmup.ms,
rechecks: warmup.rechecks, keptAlive: !!alive,
baseLatency: ac?.baseLatency ?? null, outputLatency: ac?.outputLatency ?? null,
}
/* What the candle resolved to, read off the element rather than off the
props that were meant to reach it. The flame is driven by two custom
properties written inline and consumed by calc() in the stylesheet, and
every way that can fail — the prop not arriving, the property not being
written, the calc being dropped — looks identical from up here. */
if (m === "candle") {
const el = document.querySelector<SVGElement>(".candle")
if (!el) return null
const cs = getComputedStyle(el)
const sub = (sel: string, prop: string) => {
const n = el.querySelector<SVGElement>(sel)
return n ? getComputedStyle(n).getPropertyValue(prop) : null
}
/* What is on the screen, as opposed to what the cascade resolved to. A
transform can compute correctly and still paint nothing if it is hung on
a group the shape does not sit inside, and from up here the two look the
same. This is the only reading of the two that can tell them apart. */
const rect = (sel: string) => {
const n = el.querySelector<SVGGraphicsElement>(sel)
if (!n) return null
const r = n.getBoundingClientRect()
return { top: Math.round(r.top * 10) / 10, h: Math.round(r.height * 10) / 10 }
}
return {
streak: gg.streak, phase: gg.phase,
cls: el.getAttribute("class"),
run: cs.getPropertyValue("--run"), flare: cs.getPropertyValue("--flare"),
lift: cs.getPropertyValue("--lift"),
amp: cs.getPropertyValue("--amp"), filter: cs.filter,
flick: sub(".flamewrap", "animation-duration"),
flareg: sub(".flareg", "transform"),
flameRect: rect(".flame"), waxRect: rect(".wax"),
}
}
if (m === "state") return {
phase: gg.phase, round: gg.round, right: gg.right, streak: gg.streak,
chord: symbolOf(gg.chord), notes: gg.chord.notes,
options: gg.options.map(o => ({ label: o.name, quality: o.id, pcs: optionPcs(o, gg.chord) })),
answer: gg.answer, picked: gg.picked, settings: gg.settings,
// must never exceed ROUNDS: if it does, a set is running on top of an
// old one and the summary will report more right answers than chords
played: gg.history.length,
audition: live.current.audition, setup: live.current.showSetup,
// parity is what drives the audition spring; it has to flip on every click
pulse: live.current.pulse,
}
if (m === "start") { api.current.begin(); return G.current.phase }
if (m === "next") { api.current.next(); return G.current.phase }
if (m === "again") { api.current.again(); return G.current.phase }
if (m && typeof m === "object") {
const o = m as Record<string, unknown>
if (o.sample) {
const ss: Settings = { ...DEFAULTS, ...(o.sample as Partial<Settings>) }
const n = (o.sample as { n?: number }).n ?? 6
let prev: Chord | null = null
return Array.from({ length: n }, () => {
const { chord: c, options, answer } = deal(ss, prev); prev = c
return {
chord: symbolOf(c), notes: spell(c).join(" "), answer,
options: options.map(x => ({ label: x.name, quality: x.id, pcs: optionPcs(x, c) })),
}
})
}
if (typeof o.answer === "number") {
api.current.answer(o.answer)
/* `{"answer":n,"peak":900}` answers and then watches the leap. The flare
tops out about 150ms in and a round trip back out to the terminal is
slower than that, so how tall it actually got can only be measured
from in here. Twice now a run has been declared visible on a computed
value that was climbing correctly and painting something nobody could
see; this reads the pixels instead. */
const ms = typeof o.peak === "number" ? o.peak : 0
const fl = document.querySelector<SVGGraphicsElement>(".candle .flame")
if (ms > 0 && fl) return new Promise(res => {
let peak = 0
const t0 = performance.now()
const tick = () => {
peak = Math.max(peak, fl.getBoundingClientRect().height)
if (performance.now() - t0 < ms) requestAnimationFrame(tick)
else res({ correct: G.current.correct, peak: Math.round(peak * 10) / 10 })
}
requestAnimationFrame(tick)
})
return { correct: gg.correct }
}
if (typeof o.play === "number") { press(o.play); return noteName(o.play) }
if (o.set) { setS(o.set as Partial<Settings>); return G.current.settings }
}
return undefined
}), [])
/* The chord everything on show follows: the one that was played, or the answer
you clicked to hear against it.
It is memoised because `Trace` keys its effect on the identity of the notes
array it is handed. Rebuilt per render that array was new every time, so any
unrelated re-render restarted the swell — an arpeggio fires two state updates
per note, and the picture re-sprang a dozen times while one chord played.
And it sits above the results screen's early return because hooks cannot be
conditional. Below it, the results screen renders one hook fewer than the
board does and React refuses the update. */
const heard = useMemo(
() => audition ? optionChord(g.options[audition.i], g.chord) : g.chord,
[audition, g.chord, g.options],
)
/* ---------- the scorecard as a file ----------
A canvas kept off the page, drawn the moment a set ends and turned into a
blob straight after, so the share button has an image already in hand when
it is pressed: a browser only honours a clipboard write made inside the
click that asked for it, and work done in the middle is work the click may
not survive. Same reason as `heard` above for living up here: hooks cannot
be conditional, and the results screen returns early. */
const shotRef = useRef<HTMLCanvasElement | null>(null)
const shotBlob = useRef<Blob | null>(null)
const [shotSaid, setShotSaid] = useState<"" | "copied" | "saved" | "failed">("")
useEffect(() => {
const cv = shotRef.current
if (g.phase !== "results" || !cv) return
drawShot(cv, shotOf(G.current))
shotBlob.current = null
cv.toBlob(b => { shotBlob.current = b }, "image/png")
}, [g.phase, g.last])
// whatever the button last reported goes quiet again
useEffect(() => {
if (!shotSaid) return
const t = window.setTimeout(() => setShotSaid(""), 2400)
return () => window.clearTimeout(t)
}, [shotSaid])
/** No clipboard for pictures: leave it in the downloads folder instead. */
const saveShot = (b: Blob) => {
try {
const url = URL.createObjectURL(b)
const a = document.createElement("a")
a.href = url; a.download = SHOT_FILE; a.click()
// not immediately: the download reads the url after the click returns
window.setTimeout(() => URL.revokeObjectURL(url), 10000)
setShotSaid("saved")
} catch { setShotSaid("failed") }
}
function shareShot() {
const cv = shotRef.current
if (!cv) return
const ready = shotBlob.current
const file = ready ? new File([ready], SHOT_FILE, { type: "image/png" }) : null
/* A device you hold has somewhere to send a file, and its share sheet is the
whole point of the button there. A desktop technically has one too — and
Windows Chrome will happily say so — but it is a slow dialog next to the
clipboard, which is what a scorecard is for: paste it into whatever you
were already typing in. So the sheet is offered to a coarse pointer only,
the clipboard everywhere else, and the downloads folder to a browser that
will not put a picture on one. */
const handheld = window.matchMedia?.("(pointer: coarse)").matches
if (handheld && file && navigator.canShare?.({ files: [file] })) {
navigator.share({ files: [file] }).catch(() => { /* you changed your mind */ })
return
}
// The blob goes to ClipboardItem as a promise rather than being awaited
// first, for the same reason the drawing happens ahead of time: awaiting it
// puts the write outside the click, and Safari then refuses it.
const blob = ready ? Promise.resolve(ready)
: new Promise<Blob>((ok, no) => cv.toBlob(b => b ? ok(b) : no(new Error("no image")), "image/png"))
const fail = () => setShotSaid("failed")
if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) {
blob.then(saveShot, fail)
return
}
navigator.clipboard.write([new ClipboardItem({ "image/png": blob })])
.then(() => setShotSaid("copied"), () => blob.then(saveShot, fail))
}
/* ---------- the end of a set ---------- */
if (g.phase === "results") {
const last = g.last!
// A scorecard meant to be screenshotted: it names itself, states the
// settings it was played under, and shows all ten chords as the shapes the
// rest of the bulb speaks in — green for the ones you called, red for the
// ones you did not.
return (
<div className="wrap">
<div className="card" role="group" aria-label="score">
<div className="brand">The Coltrainer</div>
<div className="tally"><b>{last.correct}</b><i>of {ROUNDS}</i></div>
<div className="under">{setupLine(s)}</div>
<ol className="grid" aria-label="every chord in the set">
{g.history.map((r, i) => (
<li key={i} className={"cell " + (r.correct ? "good" : "bad")}>
<Wheel pcs={pcsOf(r.chord.notes)} root={r.chord.rootPc}
size={72} tone={r.correct ? "good" : "bad"} />
<b>{symbolOf(r.chord)}</b>
<span className="said">{saidOf(r)}</span>
</li>
))}
</ol>
{/* nothing right means nothing to average, and an empty ruled strip
reads as something that failed to load */}
{last.avgMs !== null && (
<div className="under foot">{fmtSec(last.avgMs)} average when right</div>
)}
</div>
{/* the same card at a fixed size, for handing over as a picture — drawn,
never shown. Named rather than hidden: it is a real picture of the
results, and a name is what makes it readable back from the terminal
(`typebulb send … tb:png`) when the drawing needs checking. */}
<canvas className="shot" ref={shotRef} role="img" aria-label="scorecard picture" />
<div className="go">
<Tap className="act" onClick={tap(reconfigure)} title="settings" aria-label="settings">
<Sliders />
</Tap>
{/* Keeps the focus it is given: the button is where the answer appears,
and with no words in it the answer is the mark going green or red. */}
<Tap className={"act" + (shotSaid === "failed" ? " miss" : shotSaid ? " done" : "")}
onClick={shareShot}
title={shotSaid === "copied" ? "copied" : shotSaid === "saved" ? "saved"
: shotSaid === "failed" ? "no luck" : "a picture of this scorecard"}
aria-label="a picture of this scorecard">
{shotSaid === "failed" ? <Cross /> : shotSaid ? <Tick /> : <Camera />}
</Tap>
<Tap className="act lead" onClick={tap(again)} title="play again (↵)" aria-label="play again">
<Play />
</Tap>
</div>
</div>
)
}
/* ---------- in play ---------- */
const c = g.chord
const reveal = g.phase === "reveal"
const ready = g.phase === "ready"
// seconds left, unrounded: the candle rounds it for the digits it shows and
// keeps the fraction, which is what lets the last one dim out rather than blink
const secs = Math.max(0, left * s.clock)
const shown = heard.notes
// computed once: the stylesheet gets it as `--run`, the candle as a prop
const run = runOf(g.streak)
return (
/* `--run` lands on the root rather than inside the candle so there is one
place it is computed; everything downstream reads it by inheritance. */
<div className="wrap playing" style={{ "--run": run } as React.CSSProperties}>
<div className="hud">
{/* "Score 5 · 6 of 10". The score leads, because it is the reading you
come here for; the round is where you are in the set, so it sits
second and carries no label of its own. "Chord" named the one thing
the whole page is already about, and beside "Right" it read as if
the two counted the same things. */}
<span>Score <b>{g.right}</b></span>
<span className="muted" aria-hidden="true">·</span>
<span><b>{Math.max(1, g.round)}</b><span className="muted"> of {ROUNDS}</span></span>
{/* No streak readout here. The flame says it, in colour and in height,
and a number saying the same thing beside it is the page reading its
own picture aloud. It also came and went as a run started and broke,
which was the one thing in this row that moved. */}
{/* the status line is the way in to the settings — there is no screen
in front of the game asking you to configure it first */}
<Tap className={"setupbtn push" + (showSetup ? " on" : "")}
title="settings" aria-label="settings"
onClick={tap(() => setShowSetup(v => !v))}>
{/* seconds rather than "medium" here: next to the pool name, a second
word like "medium" reads as a second difficulty */}
<span className="txt">
{POOLS.find(p => p.id === s.pool)!.label.toLowerCase()}{s.inversions ? " · inversions" : ""} · {s.clock}s
</span>
{/* the settings the chip reports are the settings it opens, so the
mark rides inside it rather than standing as a button of its own */}
<Sliders />
</Tap>
</div>
{/* setup takes the page rather than pushing the game down it */}
{showSetup ? (
<>
<div className="settings" role="group" aria-label="setup">
<label>Chords</label>
<Seg label="chords" value={s.pool} onChange={v => setS({ pool: v })}
items={POOLS.map(p => ({ v: p.id, label: p.label }))} />
<span />
<span className="poolnote">{poolOf(s.pool).map(symOf).join(" · ")}</span>
<label>Voicing</label>
<Seg label="voicing" value={s.inversions} onChange={v => setS({ inversions: v })} items={VOICINGS} />
<label>Speed</label>
<Seg label="speed" value={s.clock} onChange={v => setS({ clock: v })} items={SPEEDS} />
</div>
<div className="go">
<Tap className="btn" onClick={tap(() => setShowSetup(false))}>Done</Tap>
</div>
</>
) : (
<>
{/* Every slot below is sized for its fullest state and rendered in both,
so nothing on the page moves when the call is made. The wheel starts as
an empty clock face; the keys start unlit; the option shapes start
hidden rather than absent. */}
<div className="stage">
{/* the wheel follows whatever is sounding: the chord, or the answer you
clicked to hear against it */}
<Wheel pcs={reveal ? pcsOf(shown) : []} root={reveal ? heard.rootPc : null}
hits={pressed} size={150} labels />
<div className="pane">
<Trace notes={ready ? NO_NOTES : shown} strike={strike} mode={reveal ? "shape" : "heard"} parts={reveal} />
</div>
{/* Name it and the wax stops where it is and keeps burning, so the flame
left standing is the record of how fast you were, and it leaps once
for the naming. Miss it, or let the clock run out, and it gets
snuffed, taking the run's accumulated light with it. */}
<Candle frac={ready ? 1 : left} secs={ready ? s.clock : secs}
out={reveal && !g.correct} won={reveal && !!g.correct} streak={g.streak}
low={!ready && left < 0.28} />
</div>
<div className="prompt">
<div className="line1">
{reveal ? (
<>
<b className="sym">{symbolOf(heard)}</b>
<span className="spelled">{spell(heard).join(" · ")}</span>
<Tap className="mini"
title="play the notes one at a time"
aria-label="play the notes one at a time"
onClick={tap(() => arpeggio(shown))}>
{/* The two long sides average out to the left of the apex, so a
triangle centred on its bounding box reads as sitting left of
centre. These vertices put its centroid on 6 instead. */}
<svg viewBox="0 0 12 12" width="16" height="16" aria-hidden="true">
<path d="M3.4 1.2 L11.2 6 L3.4 10.8 Z" />
</svg>
</Tap>
</>
) : ready ? (
/* The opening line is a question rather than the rules, and it
points at the one control that answers it: arriving on a pool
that is too easy or too hard is the way this gets closed, and
the settings chip up in the corner was not being found. */
<span className="ask">
How good is your ear? Change difficulty{" "}
<button type="button" className="asklink"
onClick={tap(() => setShowSetup(true))}>here</button>.
</span>
) : (
<span className="ask">What chord was that?</span>
)}
</div>
</div>
{/* Out of the question while the question stands: with a chord sounding and
the call unmade, picking notes off the keyboard until one matches is not
naming the chord, it is looking up the answer. Before the set starts it
is yours to noodle on, and after the call it is where the answer is
shown, so both of those leave it live. */}
<Keyboard lit={reveal ? shown : []} root={reveal ? heard.rootPc : null}
pressed={pressed} onPlay={ready || reveal ? press : undefined} />
<div className={"options" + (reveal ? " revealed" : "") + (ready ? " waiting" : "")} role="group" aria-label="options">
{g.options.map((o, i) => {
let cls = "opt"
if (reveal) cls += i === g.answer ? " good" : i === g.picked ? " bad" : " dim"
if (reveal && sprung && (i === g.answer || i === g.picked)) cls += " sprung"
if (audition?.i === i) cls += " playing" + (pulse % 2 ? " pulseA" : " pulseB")
return (
<button key={i} className={cls} onClick={tap(() => answer(i))}
aria-label={reveal ? `hear ${o.name}` : o.name}>
<kbd>{i + 1}</kbd>
<span className="lbl">{o.name}</span>
<Wheel pcs={optionPcs(o, c)} root={c.rootPc} size={34} dim={i !== g.answer} />
</button>
)
})}
</div>
<div className="go">
{reveal && g.round >= ROUNDS ? (
/* The set is over. There is nothing left to hear again that the score
card will not show you anyway, and nowhere to go but the score, so
the pair of marks gives way to the one thing left to do. */
<Tap className="btn" onClick={tap(next)} title="the score (↵)">Results</Tap>
) : (<>
{ready ? (
<Tap className={"act lead" + (everBegan ? "" : " nudge")}
onClick={tap(begin)} title="start (↵)" aria-label="start">
<Play />
</Tap>
) : (
<Tap className="act" onClick={tap(replay)} title="hear it again (space)" aria-label="hear it again">
<Note />
</Tap>
)}
{/* Always standing, so the row never changes shape under the pointer.
There is nothing to move on to until the call is made, so until then
it is dimmed and out of reach rather than absent. */}
<Tap className="act lead" disabled={!reveal}
onClick={tap(next)} title="next chord (↵)" aria-label="next chord">
<Barline />
</Tap>
</>)}
</div>
</>
)}
</div>
)
}
createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**
```css
/* One light, one dark room, one accent. The page is meant to be quiet: the only
things that move are the candle flame and the trace while the chord rings. */
html { color-scheme: dark; }
/* Not redundant with the line above: the host writes its own
html[data-theme="light"] { color-scheme: light } on top of this stylesheet,
and a bare `html` selector loses to it. This matches its specificity so the
room stays dark whichever theme the reader is in. */
html[data-theme="light"] { color-scheme: dark; }
:root {
--bg: #0e0d0c;
--pane: #151312;
--ink: #f2e9dc;
--ink2: #bfb3a2;
--ink-hi: #fff3e0; /* brighter than --ink: the score, the chord symbol */
--ink-warm: #ffe8c4; /* the scorecard's chord names */
--muted: #8b8073;
/* Four colours are spent at more than one strength, so each is written once
as an rgb triple and never again. A hand-copied rgba() of a colour that
already has a name is how a tint and its solid drift apart, and this sheet
had five of them. The candle's `--glow` has always worked this way; the
rest of the page is now catching up.
The rule that keeps it that way: BELOW THIS BLOCK, NO RULE WRITES A RAW
CHANNEL NUMBER. One that does is a strength the design does not have yet,
and either it earns a name here or it collapses into one that exists.
(The candle keeps its own palette and is the one exception.) */
--accent-rgb: 231, 167, 101;
--good-rgb: 134, 213, 154;
--bad-rgb: 255, 143, 121;
--tint-rgb: 255, 224, 190; /* the warm film every surface and line is made of */
--accent: rgb(var(--accent-rgb));
--accent-soft: rgba(var(--accent-rgb), 0.13);
--good: rgb(var(--good-rgb));
--good-soft: rgba(var(--good-rgb), 0.13);
--bad: rgb(var(--bad-rgb));
--bad-soft: rgba(var(--bad-rgb), 0.13);
/* The tint's five steps, which are all the states the design distinguishes:
a control at rest and under the pointer, a line, a line that carries
weight, and a line under the pointer. This was nine alphas, and the extra
four were drift rather than intent. */
--fill: rgba(var(--tint-rgb), 0.06);
--fill-hi: rgba(var(--tint-rgb), 0.14);
--hair: rgba(var(--tint-rgb), 0.16);
--hair2: rgba(var(--tint-rgb), 0.3);
--hair-hi: rgba(var(--tint-rgb), 0.45);
--serif: Georgia, "Iowan Old Style", "Times New Roman", serif;
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
/* Naming the answer changes six things at once, and they only read as a single
event if they move together. `--ease` carries the fades. But a fade has no
momentum to feel no matter what curve you put on it — that is why the reveal
read as dead however it was tuned. Weight comes from something moving, so
the pieces that arrive on the reveal scale in on the `spring` keyframes
below: past the mark, back under it, and to rest. */
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--reveal: 0.42s;
--spring: 0.55s;
}
* { box-sizing: border-box; }
/* Centred in the window rather than hung from the top: the board is one compact
object, and a band of dead space under it read as a page that had not
finished loading.
`safe` is the load-bearing word. Plain centring pushes the overflow of
anything taller than the window off BOTH edges, and there is no scrolling
back up to a top that has gone above zero. `safe` falls back to top-aligned
at exactly the point that would start happening, which on a short window is
the only answer. And `min-height`, not `height`, so the content's own height
is always the floor and this can only ever add space, never take it. */
body {
background: var(--bg);
min-height: 100dvh;
display: grid;
align-content: safe center;
}
.wrap {
max-width: 680px;
margin: 0 auto;
padding: 30px 20px 36px;
font: 17px/1.55 var(--sans);
color: var(--ink);
/* the run, 0..1, written over inline by the board; the results screen and the
setup panel keep the default and stay unlit */
--run: 0;
}
.muted { color: var(--muted); }
kbd {
font: inherit; font-size: 13px; line-height: 1.4; padding: 1px 5px;
border: 1px solid var(--hair); border-radius: 4px; color: var(--muted); margin-left: 4px;
}
/* the two shapes */
.stage { display: flex; align-items: center; gap: 14px; margin: 0 0 12px; }
.stage > .wheel { flex: none; }
.pane {
flex: 1; min-width: 0;
}
.trace { display: block; width: 100%; height: 120px; }
.wheel .rim { fill: none; stroke: var(--hair); stroke-width: 1; }
.wheel .tick { fill: var(--hair); }
.wheel .poly { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 1.6; stroke-linejoin: round; }
.wheel .tone { fill: var(--accent); }
.wheel .tone.root { fill: var(--ink); stroke: var(--accent); stroke-width: 1.4; }
.wheel .hit { fill: none; stroke: var(--ink); stroke-width: 2.2; }
.wheel .wl { font: 600 11px var(--sans); fill: var(--muted); text-anchor: middle; dominant-baseline: central; }
.wheel .wl.on { fill: var(--ink); }
.wheel.dim { opacity: 0.55; }
.wheel.dim .poly { fill: none; }
.wheel.good .poly { fill: var(--good-soft); stroke: var(--good); }
.wheel.good .tone { fill: var(--good); }
.wheel.good .tone.root { fill: var(--ink); stroke: var(--good); }
.wheel.bad .poly { fill: var(--bad-soft); stroke: var(--bad); }
.wheel.bad .tone { fill: var(--bad); }
.wheel.bad .tone.root { fill: var(--ink); stroke: var(--bad); }
/* the candle, which is the clock */
.candle {
width: 62px; height: 114px; flex: none; display: block;
/* A default for the one the component writes inline, so a keyframe that reads
it is never left holding an invalid value and dropped whole. `--run` is NOT
defaulted here on purpose: declaring it would shadow the board's. */
--flare: 0;
/* the flame's own colour as an rgb triple, so the glow it throws can be the
same colour it is without being written out twice per tier */
--glow: 255, 150, 50;
/* the tier's standing height, stepped rather than ramped */
--tall: 1;
/* The leap, sized. `--flare` is the wax that was left, which is the Candle's
own business; `--run` is how the set is going, which is the board's. They
meet here and nowhere else, so neither has to know about the other. Range
runs 0.21 (a slow call, cold) to 1.1 (a fast one, ten in a row). Held back
from where it was, because it now compounds with a flame already up to
1.9x tall and the two together have to clear the top of the box. */
--lift: calc(var(--flare) * (0.6 + 0.5 * var(--run)));
/* how hard it flickers, which is the inverse of the run */
--amp: calc(1 - 0.6 * var(--run));
/* The close glow on the wick, near enough left alone by the run: it opened up
by 16px over a set at one point and all that bought was a bigger blur where
a bigger flame was wanted. Snuffed, it drops to `none`, and a filter list
interpolating to none fades rather than cutting, so it goes out over the
first half second of the smoke. */
filter: drop-shadow(0 0 calc(16px + 6px * var(--run))
rgba(var(--glow), calc(0.28 + 0.1 * var(--run))));
transition: filter 0.55s var(--ease);
/* the leap stands taller than the box has room for at the top of the clock,
the same way the plume does at the bottom of it */
overflow: visible;
}
/* a lit column, so the flanks fall off to either side and the pool at the top
catches the flame */
.candle .wax { fill: url(#waxfill); }
.candle .waxtop { fill: #fff8e8; }
.candle .waxlip { fill: none; stroke: #b8a887; stroke-width: 0.8; }
.candle .flame { fill: #ffd27a; }
/* Hand-tuned, and a shade lighter than the glow it sits inside; the tiers below
take theirs from `--glow` instead, so a tier is one triple and not two. */
.candle .halo { fill: rgba(255,170,70,0.3); }
.candle .secs {
font: 700 22px var(--sans); fill: #1c1309; text-anchor: middle; dominant-baseline: central;
font-variant-numeric: tabular-nums; opacity: 0.6;
/* once it is off the wax it hangs over the flame, so it carries the room's
own colour behind it as a backing. On the wax that backing would be a black
outline on cream, so it is held at zero and faded in with everything else. */
paint-order: stroke; stroke: var(--bg); stroke-width: 3.4; stroke-opacity: 0;
transition: fill 0.7s linear, opacity 0.7s linear, stroke-opacity 0.7s linear;
}
/* out of wax and out of time: the number climbs on the clock's own frames, and
the colour catches up over the first of them */
.candle .secs.free { fill: var(--bad); opacity: 1; stroke-opacity: 1; }
/* A long run burns steadier as well as taller: the flicker loses better than
half its amplitude across ten and slows to match. That reads as composure
without spending another colour or another mark on the page. */
.candle .flamewrap { animation: flick calc(0.45s + 0.22s * var(--run)) infinite alternate ease-in-out; }
/* Longer as the run grows, so a big leap is a longer event and not only a taller
one: the flame is up there for about a third of a second at the top of a set,
against a blink at the start of one. */
.candle.won .flareg { animation: flare calc(0.9s + 0.55s * var(--run)) linear; }
/* The tier's height. Transitioned rather than snapped so the step arrives as the
flame growing into it over a third of a second, which lands under the leap
that fires at the same instant. */
.candle .stand {
transform: scaleY(var(--tall));
transition: transform 0.35s var(--ease);
}
.candle .sparks { pointer-events: none; }
.candle .spark { fill: #ffd8a2; animation: spark 1.1s ease-out both; }
/* dark by default, so it costs nothing on the page until the leap lifts it */
.candle .core { fill: #fff4dd; opacity: 0; }
.candle.won .core { animation: flarecore 1s linear; }
.candle.low .flame { fill: #ff8a52; }
.candle.low .halo { fill: rgba(255,90,40,0.36); }
/* Five in a row turns the flame green, eight blue, ten purple. These sit after
`.low` and match its one-class depth, so they win it: on a long run the flame
keeps its colour even as the clock runs down. That costs the clock less than
it looks, because the loud half of the low-time cue is the seconds climbing
off the wax and going red, which is untouched. */
.candle.run5 { --glow: 88, 226, 140; --tall: 1.4; }
.candle.run5 .flame { fill: #7cf0a2; }
.candle.run5 .spark { fill: #a6f7c2; }
.candle.run8 { --glow: 72, 168, 255; --tall: 1.65; }
.candle.run8 .flame { fill: #78c9ff; }
.candle.run8 .spark { fill: #a8dcff; }
.candle.run10 { --glow: 178, 120, 255; --tall: 1.9; }
.candle.run10 .flame { fill: #c99bff; }
.candle.run10 .spark { fill: #dcc0ff; }
/* each tier's halo is its own glow colour, so the tier names that colour once */
.candle.run5 .halo, .candle.run8 .halo, .candle.run10 .halo { fill: rgba(var(--glow), 0.33); }
/* snuffed, there is nothing left to light the room with */
.candle.out { filter: none; }
.candle .wick { stroke: #2a2118; stroke-width: 2; stroke-linecap: round; }
/* three strokes read as three strokes until they are blurred into one body */
.candle .smoke { filter: blur(1.1px); }
.candle .wisp {
/* the shape is computed per frame in Candle; the gradient does the thinning */
fill: none; stroke: url(#smokefade); stroke-width: 4.4;
stroke-linecap: round; stroke-linejoin: round;
}
.candle .wisp.two { stroke-width: 2.9; }
.candle .wisp.three { stroke-width: 1.8; }
@keyframes flick {
from { transform: scale(calc(1 - 0.06 * var(--amp))) rotate(calc(-2deg * var(--amp))); }
to { transform: scale(calc(1 + 0.06 * var(--amp))) rotate(calc(2deg * var(--amp))); }
}
/* The breath. It leaps inside a sixth of a second, ducks under its own height
as the surge passes, comes back up short of the first peak, and lands.
`--flare` scales the whole gesture, so one set of stops carries both a jump
and the barest lift. Played `linear` for the same reason as the springs
below: the stops carry the curve. */
@keyframes flare {
0% { transform: scale(1, 1); }
11% { transform: scale(calc(1 + 0.14 * var(--lift)), calc(1 + 0.50 * var(--lift))); }
25% { transform: scale(calc(1 + 0.12 * var(--lift)), calc(1 + 0.43 * var(--lift))); }
46% { transform: scale(calc(1 + 0.03 * var(--lift)), calc(1 + 0.08 * var(--lift))); }
66% { transform: scale(calc(1 + 0.02 * var(--lift)), calc(1 + 0.16 * var(--lift))); }
86% { transform: scale(1, calc(1 + 0.06 * var(--lift))); }
100% { transform: scale(1, 1); }
}
@keyframes flarecore {
0% { opacity: 0; }
10% { opacity: calc(0.95 * var(--lift)); }
30% { opacity: calc(0.7 * var(--lift)); }
60% { opacity: calc(0.2 * var(--lift)); }
100% { opacity: 0; }
}
/* An ember leaves the wick, rises, slows, and goes out. `--dx`/`--dy` are bare
numbers so they can be read as user units here and as a count in the sparks'
own arithmetic there. */
@keyframes spark {
0% { transform: translate(0, 0) scale(0.3); opacity: 0; }
14% { opacity: 1; }
70% { opacity: 0.85; }
100% {
transform: translate(calc(var(--dx) * 1px), calc(var(--dy) * 1px)) scale(0.25);
opacity: 0;
}
}
/* A damped oscillation, written out as keyframes and played `linear` so the
stops carry the curve rather than an easing function flattening it. Three
crossings is enough to read as sprung; more reads as wobble. */
@keyframes pop {
0% { transform: scale(0.55); }
40% { transform: scale(1.10); }
62% { transform: scale(0.955); }
80% { transform: scale(1.022); }
92% { transform: scale(0.992); }
100% { transform: scale(1); }
}
@keyframes rise {
0% { transform: translateY(10px) scale(0.90); opacity: 0; }
45% { transform: translateY(-2.5px) scale(1.035); opacity: 1; }
68% { transform: translateY(0.8px) scale(0.988); }
85% { transform: translateY(-0.3px) scale(1.006); }
100% { transform: none; }
}
@keyframes settle {
0% { transform: scale(0.94); }
42% { transform: scale(1.028); }
66% { transform: scale(0.99); }
84% { transform: scale(1.006); }
100% { transform: scale(1); }
}
/* Two names, one shape. CSS gives no way to replay an animation whose name has
not changed, so an audition alternates between these and the name flips on
every click. They are deliberate duplicates, not a copy-paste slip. */
@keyframes pulseA {
0% { transform: scale(0.94); }
42% { transform: scale(1.028); }
66% { transform: scale(0.99); }
84% { transform: scale(1.006); }
100% { transform: scale(1); }
}
@keyframes pulseB {
0% { transform: scale(0.94); }
42% { transform: scale(1.028); }
66% { transform: scale(0.99); }
84% { transform: scale(1.006); }
100% { transform: scale(1); }
}
@keyframes tapA {
0% { transform: scale(0.80); }
38% { transform: scale(1.14); }
60% { transform: scale(0.96); }
80% { transform: scale(1.025); }
100% { transform: scale(1); }
}
@keyframes tapB {
0% { transform: scale(0.80); }
38% { transform: scale(1.14); }
60% { transform: scale(0.96); }
80% { transform: scale(1.025); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
/* The standing height and the room's light stay: those are states rather than
motion, and they are what a run is paid in. What goes is the leap, the
flicker, and the ring under Start. */
.candle .flamewrap, .candle.won .flareg, .candle.won .core,
.candle .spark, .act.nudge { animation: none; }
.candle .stand { transition: none; }
/* with no animation to carry them off the wick they would sit on it */
.candle .sparks { display: none; }
.opt .wheel, .line1 > *, .opt.sprung,
.opt.pulseA, .opt.pulseB, .tapA, .tapB { animation: none; }
}
/* in play — slot heights are fixed so the reveal never shifts anything */
/* `align-items` is the load-bearing part. Left to its `stretch` default the
plain spans grow to the height of the tallest thing on the row, which is the
settings chip at 31px, and text in a stretched box sits at the top of it
while the chip centres its own text inside the same 31px. So the chip read
about 4px lower than the counts beside it, and the drop tracked whatever was
making the row tall rather than anything about the chip. Centred, every
item's text centre lands on the line's, and the row reads as one line. */
/* Never wraps. Wrapping is decided on what an item WANTS to be, so a chip that
wants 250px takes a whole second row the moment the counts and the gaps and
its own 26px of padding come to more than the column, and it does that while
there is still plenty of room for what is actually printed on it. Dropping a
line to save fifteen pixels is the "too enthusiastic" part. Held to one line,
the chip is the only thing here that can give, and it gives width: its label
truncates from the right and the mark stays, which on a narrow window costs a
word off a readout that is also spelled out in the panel it opens. */
.hud {
display: flex; flex-wrap: nowrap; align-items: center; gap: 6px 10px;
font-size: 16px; color: var(--ink2); margin: 0 0 16px; min-height: 22px;
}
/* the counts hold their size; they are the row's fixed content */
.hud > span { white-space: nowrap; flex: none; }
/* A word space is too tight between a label and the count it introduces: the
two read as one token. The gap after the number, before " of 10", stays a
plain space, because those two belong together, and the round's own number
leads its span with no label in front of it to be held off. */
.hud b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; margin-left: 3px; }
.hud span > b:first-child { margin-left: 0; }
.hud .push { margin-left: auto; }
/* No box at all: a status readout that underlines when you point at it, the
same gesture as the opening line's link. Dropping the border and the
horizontal padding is also what squares the row, because the ink's right edge
is now the box's right edge. It used to be inset 13px behind it and the chip
was outdented by exactly that much to compensate, which worked but left the
box overhanging into the page gutter whenever it lit up. */
.setupbtn {
font: 500 16px/1 var(--sans); padding: 7px 0; cursor: pointer;
border: 0; background: none;
color: var(--muted); transition: color 0.15s;
display: inline-flex; align-items: center; gap: 9px;
/* the one item on the row allowed to shrink, and `min-width: 0` is what lets
it shrink past its own content instead of forcing the overflow outward */
min-width: 0;
}
.setupbtn:hover { color: var(--ink2); }
/* the panel is open, so the chip holds the hover it was clicked with */
.setupbtn.on { color: var(--ink); }
/* Centring a line of text centres its em box, and an em box has room under the
baseline for descenders this string does not have — so the ink lands low while
the mark beside it, which fills its own box, lands true. The px is the gap
between those two centres, off the layout so it cannot change the row height. */
.setupbtn .txt {
position: relative; top: -1px;
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
/* Room under the baseline for the underline, which is otherwise clipped away
entirely: the button sets `line-height: 1`, so the box ends at the em edge
with the descenders already hanging outside it, and `overflow: hidden` —
which the ellipsis above requires — cuts everything past that. The padding
extends the box that gets clipped; the negative margin takes the same
amount back off the box that gets laid out, so the row's height and the
text's alignment against the mark are exactly as they were. */
padding-bottom: 6px; margin-bottom: -6px;
}
/* hud scale, not disc scale */
.setupbtn .mark { width: 17px; height: 17px; flex: none; }
/* before the first chord sounds the names are there to read, not to pick */
.options.waiting .opt { cursor: default; opacity: 0.62; }
/* held at the resting fill, which is the whole point: it must not light up */
.options.waiting .opt:hover { background: var(--fill); }
.prompt { margin: 6px 0 14px; }
.line1 {
display: flex; align-items: center; justify-content: center;
gap: 14px; flex-wrap: wrap; min-height: 42px; text-align: center;
}
/* The prompt swaps its whole contents on the call. The slot height is fixed, so
the new pieces can travel on their way in rather than cutting. */
.line1 > * { animation: rise var(--spring) linear both; }
.ask { font: 600 21px/1.3 var(--sans); }
/* Two things on this page are a phrase you can click: the opening line's "here"
and the settings chip. One underline between them, so a run of clickable text
is a single gesture the reader learns once. On the chip it goes on `.txt`
rather than on the button, because an inline-flex box does not pass a
decoration down to its flex items, and the mark should not be underlined
anyway. */
/* An action, so it is a button; it looks like a link because a phrase in the
middle of a sentence should. Being a real button also means the page's
keyboard handler already steps aside for it: Enter and space go to the
control that has focus rather than to the game. */
.asklink {
font: inherit; color: var(--accent); background: none; border: 0; padding: 0;
cursor: pointer;
}
/* `text-decoration-line`, never the `text-decoration` shorthand: the shorthand
resets the colour to `currentColor`, and this rule sits after the ones that
set it, so it would quietly paint every underline on at rest. */
.asklink, .setupbtn .txt {
text-decoration-line: underline; text-underline-offset: 3px;
text-decoration-thickness: 1px; text-decoration-color: transparent;
transition: text-decoration-color 0.15s;
}
.asklink:hover, .setupbtn:hover .txt, .setupbtn.on .txt {
text-decoration-color: var(--accent);
}
.mini {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 32px; padding: 0; border-radius: 50%;
color: var(--ink2);
}
/* Pressed, then sprung: the transform answers the finger going down, the
keyframes answer the click. Two names for the same reason as `pulse` above. */
.mini:active { transform: scale(0.88); }
/* the spring itself, on whatever `Tap` wraps: the transform answers the finger
going down, the keyframes answer the click */
.tapA { animation: tapA var(--spring) linear; }
.tapB { animation: tapB var(--spring) linear; }
.mini svg { fill: currentColor; display: block; }
.sym { font: 700 32px/1.1 var(--serif); color: var(--ink-hi); }
.spelled { font: 600 17px var(--sans); color: var(--accent); letter-spacing: 0.6px; }
/* keyboard */
.kb {
width: 100%; height: auto; display: block; margin: 4px 0 14px;
touch-action: manipulation; user-select: none;
transition: opacity var(--reveal) var(--ease);
}
/* nothing lit on it until the call is made, so it stays back until then */
.kb.dim { opacity: 0.5; }
/* Shorter than the reveal: the keys also carry the press flash, which wants to
stay crisp. */
.wk { fill: #ddd5c6; stroke: #2b2621; stroke-width: 1; transition: fill 0.18s var(--ease); }
.bk { fill: #18150f; stroke: #2b2621; stroke-width: 1; transition: fill 0.18s var(--ease); }
.wk.on { fill: var(--accent); }
.bk.on { fill: #d5792a; }
.wk.live, .bk.live { cursor: pointer; }
.wk.live:hover { fill: #f0e9da; }
.bk.live:hover { fill: #332c22; }
.wk.on.live:hover, .bk.on.live:hover { fill: #ffc478; }
.wk.press, .bk.press, .wk.on.press, .bk.on.press { fill: #ffe6ac; transition: fill 0.05s; }
.kl { font: 12px var(--sans); fill: #7a7064; text-anchor: middle; }
.rootdot { fill: #23180a; }
/* the four choices */
.options { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.opt {
font: 500 18px/1.25 var(--sans); padding: 14px 15px; text-align: left; cursor: pointer;
border-radius: 9px; border: 1px solid var(--hair); background: var(--fill);
color: var(--ink); display: flex; align-items: center; gap: 11px; min-height: 62px;
transition: transform 0.14s var(--ease),
background var(--reveal) var(--ease), border-color var(--reveal) var(--ease),
opacity var(--reveal) var(--ease), box-shadow var(--reveal) var(--ease);
}
.opt .lbl { flex: 1; min-width: 0; }
.opt kbd { flex: none; margin: 0; }
/* the option shapes are present from the first frame and merely unseen, so the
buttons never change size when the call is made. Opacity is a transition and
scale is the spring, kept apart so the `dim` wheels can settle at their own
opacity without the animation's fill overriding it. */
.opt .wheel { flex: none; visibility: hidden; opacity: 0; transition: opacity var(--reveal) var(--ease); }
.options.revealed .opt .wheel { visibility: visible; opacity: 1; animation: pop var(--spring) linear; }
.options.revealed .opt .wheel.dim { opacity: 0.55; }
.opt:hover { background: var(--fill-hi); }
.opt:active { transform: scale(0.985); }
.opt.good { border-color: rgba(var(--good-rgb), 0.65); background: var(--good-soft); }
.opt.bad { border-color: rgba(var(--bad-rgb), 0.65); background: var(--bad-soft); }
/* No fill mode: once it has sprung, the transform is released so `:active` can
still take the button on a later click. */
.opt.sprung { animation: settle var(--spring) linear; }
.opt.dim { opacity: 0.44; }
.opt.dim:hover { opacity: 0.85; }
/* Sounding now. This used to be an outline, which after the reveal sat just
outside the green or red border and read as a second ring around the same
button. A glow has no edge to double up, and it is the same treatment the
candle already uses for something lit. */
.opt.playing { box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.5); }
/* After .good/.bad, so an audition of the right answer overrides the reveal's
animation rather than tying with it and being ignored. */
.opt.pulseA { animation: pulseA var(--spring) linear; }
.opt.pulseB { animation: pulseB var(--spring) linear; }
/* Two by two at every width. One column spent four rows where two would do, and
on a short phone that was the difference between the board fitting the screen
and not, which is the one thing this layout has to get right: there is no
scrolling to an option while the candle is burning.
Two columns only fit down here if the label is given back the room the chrome
was taking. The key hint goes first, being the least useful pixel on a device
with no key to press; then the padding, the gaps and the shape come in. That
turns roughly 47px of label into roughly 100px, which is two lines for even
the longest name in the table rather than four. The button still measures
about 156 by 56 on a 360px phone, well past the size a thumb starts missing. */
@media (max-width: 430px) {
.wrap { padding: 20px 14px 24px; }
.options { gap: 7px; }
.opt { font-size: 15px; padding: 10px; gap: 7px; min-height: 56px; }
.opt kbd { display: none; }
.opt .wheel { width: 28px; height: 28px; }
}
/* buttons */
/* same gap as the four answers above, so the two rows read as one grid */
.go { margin-top: 16px; min-height: 58px; display: flex; align-items: center; justify-content: center; gap: 14px; flex-wrap: wrap; }
/* every button on the page is the same button */
.btn, .act, .mini {
cursor: pointer;
background: var(--fill); border: 1px solid var(--hair2);
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s var(--ease);
}
.btn:hover, .mini:hover, .act:not(:disabled):hover {
color: var(--ink); background: var(--fill-hi); border-color: var(--hair-hi);
}
/* …and what follows adds only shape */
.btn {
font: 600 16px/1 var(--sans); padding: 13px 20px;
border-radius: 9px; color: var(--ink);
display: inline-flex; align-items: center; gap: 8px;
}
.btn:active { transform: scale(0.95); }
.btn kbd { margin-left: 0; }
/* Rectangles are down to one: the settings panel's Done. Everywhere else a row
of them read as a wall, four answers deep already, and on the score card three
of them at a 172px floor came to more than a phone has and wrapped one onto a
row of its own. What replaced them is a disc with a mark in it and nothing
else, the key it answers to in the tooltip rather than on the page. */
.act {
color: var(--muted);
border-radius: 50%; padding: 0;
width: 58px; height: 58px; display: grid; place-items: center;
}
.act:not(:disabled):active { transform: scale(0.9); }
.act.lead { color: var(--ink); } /* the forward one carries the weight */
.act.lead:not(:disabled):hover { color: var(--accent); }
.act:disabled { opacity: 0.3; cursor: default; }
/* the share button says so in the mark, having no words to say it in */
.act.done { color: var(--good); }
.act.miss { color: var(--bad); }
/* Until the first chord has sounded, Start breathes a ring so the eye finds it.
Nothing else on the opening board announces itself: the candle is stopped, the
wheel is an empty clock face, the four names are dimmed out of reach. In the
accent, which is what this page spends on anything lit. */
.act.nudge { animation: nudge 2.8s ease-out infinite; }
@keyframes nudge {
0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.5); }
50%, 100% { box-shadow: 0 0 0 13px rgba(var(--accent-rgb), 0); }
}
/* drawn out to the edges of its own box, so what is in the disc is all mark and
no padding baked into the artwork */
.mark { width: 28px; height: 28px; fill: currentColor; display: block; }
/* The shareable copy of the scorecard. Off the page and one pixel of it, so
its 1120-wide backing store cannot lengthen the document — the host measures
an inline bulb by how tall its body scrolls. */
.shot { position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; pointer-events: none; }
/* settings & results */
.settings {
display: grid; grid-template-columns: auto 1fr; gap: 12px 16px; align-items: center;
margin: 0 0 18px; padding: 16px 18px;
border: 1px solid var(--hair); border-radius: 10px; background: var(--pane);
}
.settings label { font-size: 15px; color: var(--muted); }
.poolnote { font-size: 14px; color: var(--muted); line-height: 1.5; margin-top: -3px; }
.seg { display: inline-flex; border: 1px solid var(--hair); border-radius: 8px; overflow: hidden; }
/* a grid item stretches to its column by default, which left the segmented
controls with a rail of empty box running out to the right edge */
.settings .seg { justify-self: start; }
/* Narrow: a wrapped segmented control stops reading as one control — the second
line looks like a separate set of buttons sitting under it. So drop the
label-beside-control grid, give each control its own full-width row, and let
the options divide it evenly. */
@media (max-width: 560px) {
.settings { grid-template-columns: 1fr; gap: 5px; padding: 14px; }
.settings label { margin-top: 9px; }
.settings .seg { justify-self: stretch; display: flex; }
.settings .seg button { flex: 1; padding: 10px 4px; font-size: 13.5px; text-align: center; }
.poolnote { margin-top: 0; }
}
.seg button { font: 500 15px/1 var(--sans); padding: 10px 15px; cursor: pointer; background: transparent; color: var(--ink2); border: 0; white-space: nowrap; }
.seg button + button { border-left: 1px solid var(--hair); }
.seg button:hover { background: var(--fill-hi); }
.seg button.on { background: var(--accent-soft); color: #f0d3ab; font-weight: 600; }
/* the scorecard — built to be screenshotted, so it names itself and states
what it was played under */
.card {
border: 1px solid var(--hair); border-radius: 14px;
padding: 26px 22px 22px; margin: 8px 0 4px;
background: var(--pane); text-align: center;
}
.brand {
font: italic 700 22px/1 var(--serif); color: var(--accent);
letter-spacing: 0.3px; margin-bottom: 12px;
}
.tally { display: flex; align-items: baseline; justify-content: center; gap: 5px; }
/* Proportional figures, not tabular. Tabular ones earn their keep in the HUD,
where the count changes in place round to round; here the number is shown
once and against nothing, and the side bearing they pad a 1 with is exactly
the gap that made "1 of 10" read as two separate things. */
.tally b { font: 700 58px/1 var(--sans); color: var(--ink-hi); }
.tally i { font: 500 18px/1 var(--sans); font-style: normal; color: var(--muted); }
.under {
font-size: 14px; color: var(--muted); margin-top: 8px;
letter-spacing: 0.4px;
}
.under.foot { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--hair); }
.grid {
list-style: none; margin: 22px 0 0; padding: 0;
display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px 10px;
}
.cell { display: grid; justify-items: center; gap: 3px; min-width: 0; }
.cell b { font: 700 15px/1.2 var(--serif); color: var(--ink-warm); }
.cell .said { font-size: 12px; line-height: 1.3; color: var(--muted); min-height: 16px; }
.cell.bad .said { color: var(--bad); }
/* ten chords lay out two ways and no other: five across in two rows, or two
across in five. Four- and three-column steps left an orphan row. */
@media (max-width: 560px) {
.grid { grid-template-columns: repeat(2, 1fr); gap: 12px 10px; margin-top: 16px; }
/* five rows is a lot taller than two, and a scorecard that runs off the
bottom cannot be screenshotted */
.cell .wheel { width: 54px; height: 54px; }
.card { padding: 18px 14px 16px; }
.tally b { font-size: 44px; }
.brand { margin-bottom: 8px; }
.under.foot { margin-top: 12px; padding-top: 12px; }
}
```
**index.html**
```html
<div id="root"></div>
```
**config.json**
```json
{
"description": "Jazz chord ear training. Hear a chord, name it against the clock, then see the answer. Triads and sevenths through to altered and extended chords.",
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
}
}
```