---
format: typebulb/v1
name: 12-TET
---

**code.tsx**

```tsx
import React, { useEffect, useMemo, useRef, useState } from "react"
import { createRoot } from "react-dom/client"

// ---------- what we ask a division to approximate ----------
// Octave-reduced just intervals, tagged by the harmonic that generates them and
// weighted by 1/h. That weight is the whole argument: for a ratio a/b the two
// notes' partials coincide at every a-th partial of the lower one, so under any
// ceiling on what the ear reaches the 3rd harmonic gets N/3 places to beat where
// the 5th gets only N/5. Density of coincidences, not importance by decree. The
// argument is Galileo's; its amplitude-weighted form — roughness scaling with the
// product of the ratio's two numbers — is Benedetti's, from 1563, and the
// logarithm of that product is what tuning theory now calls Tenney height.
//
// It stops at the 5th because the question does. Common-practice harmony builds
// its minor seventh out of stacked fifths and thirds (16/9, 9/5), never from 7/4,
// so scoring twelve against the 7th harmonic measures a target it never sought —
// barbershop and blues singers reach 7/4 by bending away from 12-EDO, which is
// evidence twelve does not supply it rather than that twelve approximates it.
type Harm = { h: number; ratio: string; name: string; cents: number; slot: number }

const HARMONICS: Harm[] = [
  { h: 3, ratio: "3/2", name: "fifth", cents: 1200 * Math.log2(3 / 2), slot: 1 },
  { h: 5, ratio: "5/4", name: "third", cents: 1200 * Math.log2(5 / 4), slot: 2 },
]
const WSUM = HARMONICS.reduce((a, x) => a + 1 / x.h, 0)

type Row = {
  n: number; step: number
  errs: number[]      // one per harmonic, in HARMONICS order
  combined: number    // their weighted quadratic mean
  record: boolean     // closer than every division before it
}

// The octave is deliberately absent from this arithmetic. Its weight would be the
// heaviest of all — 1/2, coinciding at every second partial — but every equal
// division of the octave lands on it exactly, so it contributes a zero to every
// row: a constant factor on the whole chart that reorders nothing. Worse, that
// zero would drag the combined figure below the shortest stick and cost the
// ribbon the one property it is read by.
const ROWS: Row[] = (() => {
  const rows: Row[] = []
  // from 5: nothing is dropped for missing too widely — the axis is sized to hold
  // the full range instead. Six is the binding case, its fifth 98.0¢ out, which is
  // near the 100¢ ceiling any division of six could reach.
  for (let n = 5; n <= 55; n++) {
    const step = 1200 / n
    const errs = HARMONICS.map(x => Math.round(x.cents / step) * step - x.cents)
    const swe = HARMONICS.reduce((a, x, i) => a + (1 / x.h) * errs[i] * errs[i], 0)
    rows.push({ n, step, errs, combined: Math.sqrt(swe / WSUM), record: false })
  }
  let best = Infinity
  for (const r of rows) if (r.combined < best - 1e-9) { r.record = true; best = r.combined }
  return rows
})()

// ---------- geometry ----------
const W = 1000, ML = 62, MR = 16
const PW = W - ML - MR
const NDIV = 55 - 5 + 1
const BAND = PW / NDIV
// The ribbon is one bar per division, not a continuous envelope: the gap is what
// keeps 47 of them from reading as a single mass of colour, and it gives the
// focused division's sticks a slot of their own to stand in. 2 now that the bars
// carry no stroke: the gap is bare ground, so it survives at its face value.
const RGAP = 2
// A_MAX 100 clears the whole set with room to spare: the widest single error is
// 98.0¢ — six's fifth — and the widest combined figure 78.0¢, so nothing ever
// reaches the clamp and the axis needs no break marks. That is twice what the set
// from 9 asked for, and the whole cost is paid at the accurate end, where every
// mark is now 48% shorter: 53's band, 5.7px on the old scale, is 3.0px on this
// one, a hair above the 2px floor that keeps it from vanishing.
// 42, where two lines of heading once needed 56: the band's definition is gone
// from up there — it named the mark instead of the thing, and the readout under
// the toolbar already gives the figure in cents with the swatch beside it.
const A_TOP = 42, A_H = 328, A_MID = A_TOP + A_H / 2, A_BOT = A_TOP + A_H, A_MAX = 100
const AXIS = A_BOT + 8
const H = AXIS + 58                  // room for the tick labels AND a gap under them
const MID_X = ML + PW / 2

const yA = (v: number) => A_MID - (Math.max(-A_MAX, Math.min(A_MAX, v)) / A_MAX) * (A_H / 2)

// "sharp"/"flat" rather than a signed number. The axis carries direction on its
// ticks (+75, -50), so this readout is the one place it gets said in a word —
// which is what a reader who doesn't think in cents actually needs.
const off = (v: number) => `${Math.abs(v).toFixed(1)}¢ ${v >= 0 ? "sharp" : "flat"}`

// Only one division ever wears its sticks — the one the readout is describing —
// so the pair takes the whole bar: no gap between them, and together exactly its
// width, which lands them flush on its side edges (cx - GROUP_W/2 IS the bar's
// left edge once GROUP_W is the bar width). The two errors then sit inside the
// compromise that averages them, and the band survives only where a stick falls
// short of it — the quadratic mean made visible rather than merely drawn.
const GAP = 0, SW = (BAND - RGAP) / HARMONICS.length
const GROUP_W = SW * HARMONICS.length + GAP * (HARMONICS.length - 1)

// square at both ends: the data end reads as a hard stop rather than a soft one,
// and the other butts flush against the zero line
function stickPath(x: number, w: number, v: number) {
  const len = Math.max(Math.abs(yA(v) - A_MID), 2.5)
  const s = v >= 0 ? 1 : -1
  const y = A_MID - s * len
  return `M ${x} ${A_MID} L ${x} ${y} L ${x + w} ${y} L ${x + w} ${A_MID} Z`
}

// ---------- audio ----------
// One graph, and it stays up: the band figure below writes here and the figure's
// switch is the only thing that opens the gate, so what the page sounds like is
// whatever the reader has sculpted. A note is built from these partials rather
// than from an oscillator type.
const PARTIALS_N = 16
let timbreAmps: number[] = Array.from({ length: PARTIALS_N }, (_, i) => Math.round(100 / (i + 1)))

let ac: AudioContext | null = null

// 4:5:6 — root, third, fifth: built from the two harmonics the chart measures,
// which is what makes the figure and the chart the same argument.
const JUST_TRIAD = [0, HARMONICS[1].cents, HARMONICS[0].cents]
// rounding the whole interval rather than the octave-reduced one is the same thing
// here: 1200¢ is a whole number of steps in every equal division of the octave
const etTriad = (n: number) =>
  JUST_TRIAD.map(c => Math.round(c / (1200 / n)) * (1200 / n))

// ---------- timbre ----------
const ROOT_HZ = 110
// Hue is pitch class: 360° to the octave, so a harmonic's colour is simply where
// it lands inside one. Two things fall out that colouring by index could not give.
// Octaves collapse — 1, 2, 4, 8 and 16 are one colour because they are one note.
// And a hue *difference* is an interval, so H15 over H10 and H9 over H6 show the
// same rotation, both being a fifth; by index those two looked unrelated. It also
// splits what a prime-limit scheme would have merged: H9 is a whole tone, not a
// fifth, so it gets its own hue rather than H3's.
//
// Rotating the whole wheel changes no difference, which leaves HUE0 free. It is
// spent putting the fifth and the third — the two the chart measures — on the
// best-separated pair the wheel allows: 172 apart in normal vision and 180
// simulated deuteranope, where by index they sat 45° apart and scored 20.
const HUE0 = 10
const PITCH = (n: number) => ((Math.log2(n) % 1) + 1) % 1
// Lightness belongs to the theme, not the hue: the same colour has to be darker
// on paper than on ink. Read once a frame in the draw loop below.
let bandL = "38%"
const COL = (n: number) => `hsl(${HUE0 + PITCH(n) * 360}, 72%, ${bandL})`
// Literal ratios rather than a round trip through JUST_TRIAD's cents: the window
// lock below is exact only because 4:5:6 is exact, and pow(2, c/1200) would put a
// rounding error in the one place the figure cannot afford one.
const JUST_RATIOS = [1, 5 / 4, 3 / 2]
// each chord tone wears the colour of the partial it stands in for: the root is
// H1, the major third H5, the fifth H3 — the same three the chart is built on
const VOICE_H = [1, 5, 3]
// Sign-free series only. A triangle needs alternating phase, and an editor with
// no phase control would be drawing a shape it cannot actually make.
const TIMBRES: Record<string, (n: number) => number> = {
  sine: n => (n === 1 ? 1 : 0),
  sawtooth: n => 1 / n,
  square: n => (n % 2 === 1 ? 1 / n : 0),
}
// One period of the just 4 : 5 : 6 chord over ROOT_HZ — the window the wave is
// drawn in. A voice's phase splits in two: f·u carries it across the window, and
// drift·τ carries it with the clock, where drift is how far that voice sits from
// where whole-number ratios would put it. So the picture holds still exactly to
// the extent the division is in tune, and walks through itself at the mistuning
// in hertz otherwise — faster on the high partials, since drift scales with k.
// That is the beating mechanism, drawn rather than described.
//
// Sampling real time in steps of LOCK would be exact too, but at only 27.5 steps
// a second: fine for twelve's 1.1 Hz drift, useless strobing for five's 7.6.
const LOCK = 4 / ROOT_HZ
const WAVE_H = 92, SPEC_H = 150, WCOLS = 600
// canvas has no cascade, so the page's --t-xs step is restated here rather than
// re-invented: these numbers are the same rank of label as the chart's dense ticks
const LABEL_PX = 13

// A graph that stays up, so play is a switch and everything else is a selector:
// timbre, one note or chord, and which tuning all retune what is already sounding
// rather than restarting it.
let vMaster: GainNode | null = null
let vNodes: Array<{ osc: OscillatorNode; gain: GainNode }> = []

function voiceEnsure(): boolean {
  if (vMaster) return true
  try {
    if (!ac) ac = new ((window as any).AudioContext || (window as any).webkitAudioContext)()
    if (!ac) return false
    if (ac.state === "suspended") ac.resume()
    const master = ac.createGain()
    master.gain.value = 0
    const lp = ac.createBiquadFilter()
    lp.type = "lowpass"; lp.frequency.value = 4000
    master.connect(lp); lp.connect(ac.destination)
    for (let v = 0; v < 3; v++) for (let k = 1; k <= PARTIALS_N; k++) {
      const osc = ac.createOscillator(); osc.type = "sine"
      const gain = ac.createGain(); gain.gain.value = 0
      osc.connect(gain); gain.connect(master); osc.start()
      vNodes.push({ osc, gain })
    }
    vMaster = master
    return true
  } catch {
    return false
  }
}

// `ratios` is one entry for a note, three for a chord; the spare voices go silent
// rather than being torn down, so switching back costs nothing
function voiceSet(ratios: number[], on: boolean) {
  if (!ac || !vMaster) return
  const t = ac.currentTime
  const sum = timbreAmps.reduce((a, x) => a + x, 0)
  const norm = 1 / (ratios.length * Math.max(1, sum / 100))
  let i = 0
  for (let v = 0; v < 3; v++) for (let k = 1; k <= PARTIALS_N; k++, i++) {
    const used = v < ratios.length
    if (used) vNodes[i].osc.frequency.setTargetAtTime(ROOT_HZ * ratios[v] * k, t, 0.01)
    vNodes[i].gain.gain.setTargetAtTime(used ? (timbreAmps[k - 1] / 100) * norm : 0, t, 0.02)
  }
  vMaster.gain.setTargetAtTime(on ? 0.5 : 0, t, 0.03)
}

const cssVar = (n: string, f: string) =>
  getComputedStyle(document.documentElement).getPropertyValue(n).trim() || f

// `n`, `tuning` and `shape` come from the chart and the buttons beside it: all
// selectors of what plays, and the switch below is the only thing that starts it.
function Timbre({ n, tuning, shape, setShape, onFail }: {
  n: number
  tuning: "just" | "edo"
  shape: "note" | "chord"
  setShape: (s: "note" | "chord") => void
  onFail: () => void
}) {
  const [timbre, setTimbre] = useState<string | null>("sawtooth")
  const [playing, setPlaying] = useState(false)
  // Cleared on the first press: until then the button breathes a ring. Nothing on
  // this page makes a sound until it is pressed, and a page that is silent by
  // default has to say so somewhere other than in prose.
  const [everPlayed, setEverPlayed] = useState(false)
  const hot = useRef<number | null>(null)
  const tet = useMemo(() => etTriad(n).map(c => Math.pow(2, c / 1200)), [n])
  const full = useMemo(() => tuning === "just" ? JUST_RATIOS : tet, [tuning, tet])
  // the rAF loop and the pointer handlers are outside React's render, so what
  // they need has to reach them by ref
  const fullRef = useRef(full)
  const shapeRef = useRef(shape)
  const sound = useRef<{ r: number[]; on: boolean }>({ r: [1], on: false })
  const wTet = useRef<HTMLCanvasElement | null>(null)
  const spec = useRef<HTMLCanvasElement | null>(null)

  useEffect(() => { shapeRef.current = shape }, [shape])
  useEffect(() => {
    fullRef.current = full
    sound.current = { r: shape === "note" ? [full[0]] : full, on: playing }
    voiceSet(sound.current.r, sound.current.on)
  }, [full, shape, playing])

  // column geometry of the band editor, shared by drawing and hit-testing
  const geom = (w: number) => {
    const padX = 10, top = 20, bottom = SPEC_H - 28
    const slot = (w - padX * 2) / PARTIALS_N
    // gap proportional to the slot rather than a flat 12: at sixteen bands a
    // fixed gap eats a third of each one
    return { padX, top, bottom, slot, trackW: Math.min(slot * 0.8, 84) }
  }

  const posAt = (e: React.PointerEvent) => {
    const el = spec.current
    if (!el) return null
    const r = el.getBoundingClientRect()
    if (!r.width || !r.height) return null
    const w = el.clientWidth
    const g = geom(w)
    const x = (e.clientX - r.left) * (w / r.width)
    const y = (e.clientY - r.top) * (SPEC_H / r.height)
    const idx = Math.max(0, Math.min(PARTIALS_N - 1, Math.floor((x - g.padX) / g.slot)))
    let amp = Math.max(0, Math.min(100, Math.round(((g.bottom - y) / (g.bottom - g.top)) * 100)))
    // snap the last few percent: silencing a partial outright is the whole
    // gesture here, and a stray 2% left behind still beats
    if (amp <= 3) amp = 0
    if (amp >= 97) amp = 100
    return { idx, amp }
  }

  const setBand = (idx: number, amp: number) => {
    timbreAmps[idx] = Math.max(0, Math.min(100, Math.round(amp)))
    hot.current = idx
    voiceSet(sound.current.r, sound.current.on)
    setTimbre(null)
  }

  const paint = (e: React.PointerEvent) => {
    const p = posAt(e)
    if (p) setBand(p.idx, p.amp)
  }

  const applyTimbre = (name: string) => {
    const fn = TIMBRES[name]
    timbreAmps = Array.from({ length: PARTIALS_N }, (_, i) => Math.round(fn(i + 1) * 100))
    setTimbre(name)
    voiceSet(sound.current.r, sound.current.on)
  }

  const togglePlay = () => {
    if (!voiceEnsure()) { onFail(); return }
    setEverPlayed(true)
    setPlaying(p => !p)
  }

  const onKey = (e: React.KeyboardEvent) => {
    const cur = hot.current ?? 0
    switch (e.key) {
      case "ArrowLeft": hot.current = Math.max(0, cur - 1); break
      case "ArrowRight": hot.current = Math.min(PARTIALS_N - 1, cur + 1); break
      case "ArrowUp": setBand(cur, timbreAmps[cur] + (e.shiftKey ? 2 : 10)); break
      case "ArrowDown": setBand(cur, timbreAmps[cur] - (e.shiftKey ? 2 : 10)); break
      case "Home": setBand(cur, 0); break
      case "End": setBand(cur, 100); break
      default: return
    }
    e.preventDefault()
  }

  useEffect(() => {
    let id = 0
    const dpr = Math.min(2, window.devicePixelRatio || 1)
    const fit = (c: HTMLCanvasElement | null, h: number) => {
      if (!c?.parentElement) return 0
      const w = Math.max(280, Math.floor(c.parentElement.getBoundingClientRect().width))
      if (c.width !== Math.round(w * dpr)) {
        c.width = Math.round(w * dpr); c.height = Math.round(h * dpr)
        c.style.height = `${h}px`
        c.getContext("2d")!.setTransform(dpr, 0, 0, dpr, 0, 0)
      }
      return w
    }

    const drawWave = (ctx: CanvasRenderingContext2D, w: number, ratios: number[],
                      tau: number, shape: "note" | "chord") => {
      if (!w) return
      const mid = WAVE_H / 2
      ctx.clearRect(0, 0, w, WAVE_H)
      ctx.strokeStyle = cssVar("--grid", "#ddd"); ctx.lineWidth = 1
      ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(w, mid); ctx.stroke()

      const a = timbreAmps
      const total = a.reduce((s, x) => s + x, 0) || 1
      const nv = shape === "chord" ? 3 : 1
      const scale = (mid * 0.88) / (nv * (total / 100))
      // see the note on LOCK: across the window at f, through the clock at drift
      const f = ratios.map(r => ROOT_HZ * r)
      const drift = ratios.map((r, v) => ROOT_HZ * (r - JUST_RATIOS[v]))
      const ALL = Array.from({ length: PARTIALS_N }, (_, i) => i + 1)
      const trace = (vs: number[], ks: number[]) => {
        ctx.beginPath()
        for (let x = 0; x <= WCOLS; x++) {
          const u = (x / WCOLS) * LOCK
          let s = 0
          for (const v of vs)
            for (const k of ks)
              if (a[k - 1]) s += (a[k - 1] / 100) *
                Math.sin(2 * Math.PI * k * (f[v] * u + drift[v] * tau))
          const px = (x / WCOLS) * w, y = mid - s * scale
          x === 0 ? ctx.moveTo(px, y) : ctx.lineTo(px, y)
        }
        ctx.stroke()
      }
      // faint, the parts: a chord's parts are its notes, one note's are its
      // partials — either way the bold line is their sum
      ctx.globalAlpha = 0.32; ctx.lineWidth = 1
      if (shape === "chord") {
        for (let v = 0; v < 3; v++) { ctx.strokeStyle = COL(VOICE_H[v]); trace([v], ALL) }
      } else {
        for (const k of ALL) {
          if ((a[k - 1] / 100) * scale < 0.4) continue   // sub-pixel partials draw as noise
          ctx.strokeStyle = COL(k); trace([0], [k])
        }
      }
      ctx.globalAlpha = 1
      ctx.strokeStyle = cssVar("--sum", "#101010"); ctx.lineWidth = 2.2
      trace(shape === "chord" ? [0, 1, 2] : [0], ALL)
    }

    const drawSpec = (ctx: CanvasRenderingContext2D, w: number) => {
      if (!w) return
      ctx.clearRect(0, 0, w, SPEC_H)
      const g = geom(w)
      const plotH = g.bottom - g.top
      ctx.strokeStyle = cssVar("--base", "#ccc"); ctx.lineWidth = 1
      ctx.beginPath(); ctx.moveTo(g.padX, g.bottom); ctx.lineTo(w - g.padX, g.bottom); ctx.stroke()
      ctx.textAlign = "center"
      ctx.font = `${LABEL_PX}px system-ui, sans-serif`
      // Label every band while the widest number still fits with a gap either
      // side. Measured, not guessed at a slot width: the font belongs to the
      // browser, and a hardcoded threshold was dropping labels with room to spare.
      const step = ctx.measureText(String(PARTIALS_N)).width + 4 <= g.slot ? 1 : 2
      for (let i = 0; i < PARTIALS_N; i++) {
        const cx = g.padX + (i + 0.5) * g.slot
        const x = cx - g.trackW / 2
        const on = hot.current === i
        ctx.globalAlpha = on ? 0.18 : 0.08
        ctx.fillStyle = COL(i + 1)
        ctx.fillRect(x, g.top, g.trackW, plotH)
        ctx.globalAlpha = 1
        const bh = (timbreAmps[i] / 100) * plotH
        ctx.fillStyle = COL(i + 1)
        if (bh > 0.5) ctx.fillRect(x, g.bottom - bh, g.trackW, bh)
        // a cap even at zero, so a silenced partial still shows a handle to grab
        ctx.fillRect(x, g.bottom - Math.max(bh, 3), g.trackW, 3)
        ctx.fillStyle = cssVar("--muted", "#888")
        if (on || i === 0 || (i + 1) % step === 0) {
          ctx.font = `${on ? "600 " : ""}${LABEL_PX}px system-ui, sans-serif`
          ctx.fillText(String(i + 1), cx, SPEC_H - 9)
        }
      }
    }

    const loop = () => {
      const tau = performance.now() / 1000
      bandL = cssVar("--band-l", "38%")
      const e = wTet.current, sc = spec.current
      if (e && sc) {
        drawWave(e.getContext("2d")!, fit(e, WAVE_H), fullRef.current, tau, shapeRef.current)
        drawSpec(sc.getContext("2d")!, fit(sc, SPEC_H))
      }
      id = requestAnimationFrame(loop)
    }
    id = requestAnimationFrame(loop)
    return () => cancelAnimationFrame(id)
  }, [])

  return (
    <>
      <div className="toolbar three">
        <button className={`playbtn${playing ? " on" : ""}${everPlayed ? "" : " nudge"}`}
          onClick={togglePlay}>
          {playing ? "■ stop" : "▶ play"}
        </button>
        <div className="seg" role="group" aria-label="timbre">
          {Object.keys(TIMBRES).map(t => (
            <button key={t} className={timbre === t ? "on" : ""}
              onClick={() => applyTimbre(t)}>{t}</button>
          ))}
        </div>
        <div className="seg" role="group" aria-label="what to draw">
          <button className={shape === "note" ? "on" : ""}
            onClick={() => setShape("note")}>one note</button>
          <button className={shape === "chord" ? "on" : ""}
            onClick={() => setShape("chord")}>chord</button>
        </div>
      </div>

      <div className="wrow">
        <span className="wlab">
          {shape === "note" ? "the harmonic series"
            : tuning === "just" ? "whole-number ratios" : `${n} equal divisions`}
        </span>
        <canvas ref={wTet} className="wcanvas"
          aria-label={shape === "note"
            ? "one note and its harmonic series"
            : tuning === "just" ? "the 4:5:6 chord in whole-number ratios"
              : `the 4:5:6 chord in ${n} equal divisions`} />
      </div>
      <div className="wrow">
        <canvas ref={spec} className="wcanvas grab" tabIndex={0}
          aria-label={`timbre: how loud each of the first ${PARTIALS_N} harmonics is. Arrow keys adjust.`}
          onPointerDown={e => {
            (e.target as HTMLElement).setPointerCapture(e.pointerId)
            paint(e)
          }}
          onPointerMove={e => { if (e.buttons) paint(e); else hot.current = posAt(e)?.idx ?? null }}
          onPointerLeave={() => { hot.current = null }}
          onKeyDown={onKey} />
      </div>
    </>
  )
}

// ---------- chart ----------
function Chart({ rows, sorted, hover, setHover, sel, onPick }: {
  rows: Row[]; sorted: boolean
  hover: number | null; setHover: (i: number | null) => void
  sel: number; onPick: (n: number) => void
}) {
  const cx = (i: number) => ML + (i + 0.5) * BAND
  const selIdx = rows.findIndex(r => r.n === sel)
  // exactly one division wears its sticks, and it is the one the readout is
  // describing — so the two never disagree, and no two groups can overlap
  const focus = hover !== null ? hover : (selIdx >= 0 ? selIdx : null)

  // The compromise itself, and the only thing drawn for every division: ±combined
  // about the zero line. It needs no scale of its own because it is bounded by the
  // sticks it summarises — a weighted quadratic mean never exceeds the largest
  // error or falls below the smallest — so it reads as the width of the
  // compromise, pinching shut where a division gets everything close. Hovering
  // drops that division's sticks inside it.
  const bars = useMemo(() => rows.map((r, i) => {
    const y = yA(r.combined)
    // a floor of 2: a division that gets everything close should still leave a
    // mark to aim at, rather than pinching out of existence
    return { n: r.n, x: ML + i * BAND + RGAP / 2, y, h: Math.max(yA(-r.combined) - y, 2) }
  }), [rows])

  return (
    <svg viewBox={`0 0 ${W} ${H}`} className="chart" role="img"
      aria-label="Tuning error of each equal temperament against the 3rd and 5th harmonics">

      {/* the pinned division, called out behind everything else */}
      {selIdx >= 0 && (
        <rect x={ML + selIdx * BAND} y={A_TOP} width={BAND} height={AXIS + 32 - A_TOP}
          className="selband" />
      )}

      <text x={MID_X} y={A_TOP - 18} className="paneltitle">
        Pitch Compromise for each Equal Division of Octave
      </text>
      {/* every 25¢, where the old scale ruled every 25¢ over half the range: the
          same two lines per half would now leave everything from 19 up with no
          reference nearer than 82px, and that end is where the small figures the
          chart is arguing about all live. */}
      {[-100, -75, -50, -25, 25, 50, 75, 100].map(v => (
        <line key={v} x1={ML} x2={ML + PW} y1={yA(v)} y2={yA(v)} className="grid" />
      ))}
      {[-100, -75, -50, -25, 0, 25, 50, 75, 100].map(v => (
        <text key={v} x={ML - 11} y={yA(v) + 6} className="ytick">{v > 0 ? `+${v}` : v}</text>
      ))}
      <g className="rmsband">
        {bars.map(b => (
          <rect key={b.n} x={b.x} y={b.y} width={BAND - RGAP} height={b.h} />
        ))}
      </g>
      <line x1={ML} x2={ML + PW} y1={A_MID} y2={A_MID} className="zero" />

      {/* the focused division's two harmonics, one stick each, fifth first */}
      {focus !== null && (
        <g className="sticks">
          {rows[focus].errs.map((v, i) => (
            <path key={HARMONICS[i].h} className={`bar s${HARMONICS[i].slot}`}
              d={stickPath(cx(focus) - GROUP_W / 2 + i * (SW + GAP), SW, v)} />
          ))}
        </g>
      )}

      {/* x axis */}
      <line x1={ML} x2={ML + PW} y1={AXIS} y2={AXIS} className="baseline" />
      {rows.map((r, i) => (
        <line key={r.n} x1={cx(i)} x2={cx(i)} y1={AXIS}
          y2={AXIS + (!sorted && r.n % 5 === 0 ? 6 : 3)}
          className="baseline" />
      ))}
      {/* Each new record, as a bead on the axis — in both orders. Being a record is
          a fact about the division, not about where it sits, and sorted the beads
          show what the n-ordered view cannot: that "a record" and "accurate" are
          different things. Twelve wears one from well down the ranking.
          Hence "smaller" rather than "before it" in the legend: that has to read
          correctly when left-to-right no longer means ascending n. */}
      {rows.map((r, i) => r.record ? (
        <polygon key={r.n} className="winner"
          points={`${cx(i) - 5},${AXIS - 9} ${cx(i) + 5},${AXIS - 9} ${cx(i)},${AXIS - 0.5}`} />
      ) : null)}
      {/* Sorted, a column's place means its rank, so which n sits there is the one
          thing position no longer tells you — label them all, smaller to fit. Every
          fifth n is only a sensible tick when n is what runs along the axis. */}
      {rows.map((r, i) => sorted || r.n % 5 === 0 ? (
        <text key={r.n} x={cx(i)} y={AXIS + 26}
          className={`xtick${sorted ? " dense" : ""}${r.n === sel ? " on" : ""}`}>{r.n}</text>
      ) : null)}
      {/* the axis name and the beads' key on one line, since they are one line's
          worth of type and both belong to the axis under them. The glyph is in
          .winner, so the ▼ in the sentence is the identical fill to the ▼ it names. */}
      <text x={MID_X} y={H - 6} className="axtitle">
        {"divisions per octave · "}
        <tspan className="winner">▼</tspan>
        {" closer than every smaller division"}
      </text>

      {/* hover / click targets */}
      {rows.map((r, i) => (
        <rect key={r.n} x={ML + i * BAND} y={A_TOP} width={BAND} height={AXIS + 32 - A_TOP}
          className={`hit${hover === i ? " hot" : ""}`}
          onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}
          onClick={() => onPick(r.n)} />
      ))}
    </svg>
  )
}

// ---------- pieces ----------
type Sort = "n" | "compromise"

function SortSeg({ sortBy, setSortBy }: { sortBy: Sort; setSortBy: (s: Sort) => void }) {
  return (
    <div className="seg" role="group" aria-label="sort order">
      <button className={sortBy === "n" ? "on" : ""} onClick={() => setSortBy("n")}>by n</button>
      <button className={sortBy === "compromise" ? "on" : ""}
        onClick={() => setSortBy("compromise")}>by compromise</button>
    </div>
  )
}

// The live readout, and the chart's legend — the same colours either way, so
// splitting them left the key and its numbers in different places. Follows the
// cursor over the chart, falls back to the pinned division. It is also what
// relieves the low-contrast series colours: every harmonic is named and numbered
// here, so identity never rests on hue alone.
function Readout({ r }: { r: Row }) {
  return (
    <div className="readout" role="group" aria-label="current division" aria-live="polite">
      <span className="pn-n">{r.n}<span className="suf">-EDO</span></span>
      <span className="pn-c">
        <i className="sw band" />{r.combined.toFixed(1)}¢
        <span className="suf">combined</span>
      </span>
      {HARMONICS.map((x, i) => (
        <span key={x.h} className="off">
          {/* the explicit space matters: JSX drops the newline before <b>, so
              without it this reads "fifth 3/22.0¢ flat" */}
          <i className={`sw s${x.slot}`} />{x.name} {x.ratio}{" "}
          <b>{off(r.errs[i])}</b>
        </span>
      ))}
    </div>
  )
}

function App() {
  const [sortBy, setSortBy] = useState<Sort>("n")
  const [sel, setSel] = useState(12)
  const [hover, setHover] = useState<number | null>(null)
  const [audioOk, setAudioOk] = useState(true)
  // which tuning the figure draws and sounds: the pinned division, or the pure
  // ratios it is being measured against
  const [tuning, setTuning] = useState<"just" | "edo">("edo")
  // one note is exactly periodic — its partials are whole multiples of one
  // fundamental — so that view never moves, and no choice of tuning changes it.
  // Which is why every tuning selector below forces it back to the chord.
  const [shape, setShape] = useState<"note" | "chord">("chord")

  const rows = useMemo(
    () => sortBy === "n" ? ROWS : [...ROWS].sort((a, b) => a.combined - b.combined),
    [sortBy])

  const cur = ROWS.find(r => r.n === sel)!
  const shown = hover === null ? cur : rows[hover]
  // a selector, not a trigger: it says what plays, the figure's switch starts it
  const pick = (n: number) => { setSel(n); setTuning("edo"); setShape("chord") }

  return (
    <div className="wrap">
      <h1>Why split the octave into 12 notes?</h1>
      <div className="lede">
        <p>
          If you look at piano keys, or frets on a guitar, they repeat every 12 notes (semitones). This spans an <i>octave</i>, or doubling of frequency. In equal temperament tuning (ET), each incremental note multiplies the frequency by the same factor. With 12 steps to an octave that factor is the 12th root of 2, or about 1.059. But why 12?
        </p>
        <p>
          Real instruments, such as a cello, have a <i>fundamental</i> pitch each note resonates at. But that's not the <i>only</i> pitch it resonates at. Stacked on top are overtones, also known as harmonics, that result in a note's <i>timbre</i>. So an A note, with a frequency of 110 hertz (110 oscillations per second), will have the overtones 220, 330, 440 etc., which translate to the 2nd, 3rd, 4th harmonic etc. These form the harmonic series.
        </p>
      </div>

      <div className="sect">
        <Timbre n={sel} tuning={tuning} shape={shape} setShape={setShape}
          onFail={() => setAudioOk(false)} />
      </div>

      <div className="lede">
        <p>
          When more than one note is played at the same time, these harmonics can <i>clash</i>. The chart below lets you hear the clash for different octave subdivisions. The waveform above lets you see the clash: integer ratios are still while irrational ratios move.
        </p>
      </div>

      <div className="sect">
        <div className="toolbar">
          <SortSeg sortBy={sortBy} setSortBy={setSortBy} />
          {!audioOk && <span className="dim">audio unavailable here</span>}
          <span className="tiphost push" data-tip="The three notes vibrate in the proportion 4 : 5 : 6. The bottom note vibrates 110 times a second, the middle 137.5 and the top 165. 5 against 4 is the major third, 6 against 4 is the fifth.">
            <button className={`playbtn${tuning === "just" ? " on" : ""}`}
              onClick={() => {
                setTuning(t => t === "just" ? "edo" : "just")
                setShape("chord")
              }}>just 4:5:6</button>
          </span>
        </div>

        <Readout r={shown} />

        {/* on a narrow screen the chart scrolls sideways rather than scaling its
            type down with the viewBox until it's unreadable */}
        <div className="chartwrap">
          <Chart rows={rows} sorted={sortBy !== "n"} hover={hover} setHover={setHover}
            sel={sel} onPick={pick} />
        </div>
      </div>

      <div className="lede outro">
        <p>
          Why does the clash occur? Consider the 3rd harmonic: a multiple of <i>3</i>. If the fundamental is 110 Hz, then it resonates at 330 Hz. But is there a piano note at 330 Hz? Yes, but critically, <i>not exactly</i>. Pianos are inaccurate for various reasons, but even for an ideal vibrating string, the nearest note would be 220 Hz × 1.059^7, i.e. <i>7</i> notes above the A at 220 Hz. The clash is inevitable, and caused by the <i>irrational</i> geometric sequence not equalling the <i>integer</i> harmonic ratios.
        </p>
        <p>
          So why 12 subdivisions? History is a factor, but to avoid a huge digression, 12 equal divisions gives you an incredibly accurate 3rd harmonic, and a passably accurate 5th harmonic. Musicians not caged by their instruments, like singers and violinists, can even dynamically adjust a note for more consonance. Assuming consonance is desired. Vibrato is periodic clashing, but pleasantly fattens the timbre. Or consider blues, made up of dominant 7th chords, each with a dissonant b5 interval between the 3rd and 7th notes. Imagine the reaction of a Gregorian choir from the year 1200, when even <i>major 3rds</i> were considered dissonant!
        </p>
        <p>
          So why not 19 or 31? Compared to 12: more clash on the 3rd harmonic, less clash on the 5th harmonic. The better 5th harmonic is somewhat useful musically. But their real appeal arguably encompasses the very things that stymie their adoption: their exoticness and complexity. Consider the difference in pitch between a third and a fourth (a fifth <i>down</i> from an octave). It's 1 step away, or a semitone, with 12 subdivisions. With 19 subdivisions, it's <i>2</i> steps away, with 31, it's <i>3</i> steps away. Now imagine singing a tune that actually uses those steps. Show me a party where people sing Happy Birthday in tune and then maybe our culture is ready for it! But ultimately a musical system doesn't need to be and cannot be perfect. It just needs to provide a rich enough palette of consonance and dissonance to allow an artist to express their feelings. At a certain point the paint is not the problem. Most popular music by <i>choice</i> uses a limited subset of the color 12 subdivisions offers.
        </p>
      </div>
    </div>
  )
}

createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**

```css
:root {
  color-scheme: light;
  /* Five steps, and nothing on the page is allowed a size outside them. What was
     here before was 12 / 13.5 / 14 / 14.5 / 16 / 18 / 19 / 24 — eight sizes, most
     of them a half-pixel apart from a neighbour for no reason anyone could state,
     which is not a hierarchy but a pile. --t is the body and everything the chart
     is read by; --t-sm is every control and caption; --t-xs is the two places type
     has to fit inside a bar. */
  --t-xs: 13px;
  --t-sm: 14px;
  --t: 16px;
  --t-lg: 18px;
  --t-xl: 24px;
  --surface: #fcfcfb;
  --ink: #0b0b0b;
  --ink2: #52514e;
  --muted: #898781;
  --grid: #e1e0d9;
  --base: #c3c2b7;
  --hair: rgba(11, 11, 11, 0.10);
  /* The chart's two series are no longer a palette of their own: they are H3 and
     H5 read off the same pitch-class wheel the band strip uses, so the fifth and
     the third are one colour each across the whole page. Written as hsl() over the
     shared --band-l so the two can never drift apart. */
  --band-l: 38%;
  --s1: hsl(220.6, 72%, var(--band-l));   /* the fifth — H3, 702¢ */
  --s2: hsl(125.9, 72%, var(--band-l));   /* the third — H5, 386¢ */
  --bandfill: #c6c39a;
  --wash: rgba(11, 11, 11, 0.05);
  --selwash: rgba(11, 11, 11, 0.085);
  /* The sum takes a neutral for the same reason the chart's combined error does:
     it is what the parts add up to, not another part. A hue would read as a
     seventeenth partial — and at sixteen bands the rainbow is 22.5° apart, so
     every hue is already spoken for. Green was Overtones', where it had fewer
     bands to collide with. */
  --sum: #101010;
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --surface: #1a1a19;
  --ink: #8393ab;
  --ink2: #bac0c9;
  --muted: #858b95;
  --grid: #2c2c2a;
  --base: #383835;
  --hair: rgba(255, 255, 255, 0.10);
  --band-l: 62%;
  --bandfill: #a79d55;
  --wash: rgba(255, 255, 255, 0.07);
  --selwash: rgba(255, 255, 255, 0.11);
  --sum: #e9e9e4;
}

/* the surface belongs to the page, not just the column: past the max-width the
   background would otherwise fall through to whatever the host paints, and the
   column would read as a card floating on someone else's colour */
body { background: var(--surface); }

.wrap {
  /* 1036 = the chart's 1000px viewBox plus these two 18px gutters, so the chart
     renders at exactly 1:1 and a font-size inside the SVG is that many pixels on
     screen. At 1000 the gutters ate into it: everything in the chart came out 4%
     small on a wide window and far worse on a narrow one, which is why no amount
     of raising the numbers was fixing it. */
  max-width: 1036px;
  margin: 0 auto;
  padding: 22px 18px 26px;
  font: var(--t)/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
  color: var(--ink);
  background: var(--surface);
}

h1 { font-size: var(--t-xl); line-height: 1.2; margin: 0 0 10px; letter-spacing: -0.015em; }
.lede { margin: 0 0 18px; color: var(--ink2); }
.lede p { margin: 0 0 10px; }
.lede p:last-child { margin-bottom: 0; }
.lede b { color: var(--ink); font-weight: 600; }
.lede i { color: var(--ink); }
.lede.outro { margin: 20px 0 0; }

.toolbar {
  display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 6px;
}
/* the sort control holds the left; the play button is pushed to the far right */
.toolbar .push { margin-left: auto; }
.sw { width: 12px; height: 12px; border-radius: 3px; flex: none; }
.sw.s1 { background: var(--s1); }
.sw.s2 { background: var(--s2); }
/* the ribbon's key, and now exactly a miniature of it: one solid fill, no ring.
   The ring existed to make a translucent chip findable at 12px — a solid one needs
   no such help, and dropping it keeps the key honest about the mark. */
.sw.band { background: var(--bandfill); }

.seg { display: inline-flex; border: 1px solid var(--hair); border-radius: 8px; overflow: hidden; }
.seg button {
  font: inherit; font-size: var(--t-sm); padding: 6px 14px; cursor: pointer;
  background: transparent; color: var(--ink2); border: 0;
}
.seg button + button { border-left: 1px solid var(--hair); }
.seg button.on { background: var(--wash); color: var(--ink); font-weight: 600; }

/* Fluid, with min-width as the legibility floor: below it the wrapper scrolls
   instead. Pinning the chart at its full 1000px put a scrollbar under every window
   narrower than the column, which is a worse trade than a few percent of scale. */
.chartwrap { overflow-x: auto; }
.chart { width: 100%; min-width: 700px; height: auto; display: block; overflow: visible; }

.grid { stroke: var(--grid); stroke-width: 1; }
.zero { stroke: var(--base); stroke-width: 1.5; }
.baseline { stroke: var(--base); stroke-width: 1; }
/* ink, not a hue of its own: being a record is a fact about the combined error, and
   the chart spends its three hues on the three things it measures — a fourth would
   read as a fourth series */
.winner { fill: var(--ink2); }
/* the combined compromise, in place of a second chart, and the third of the three
   marks — but not a third series, so it takes a neutral rather than a hue. It is the
   envelope the two sticks live inside, and a third hue both competed with them for
   attention and ran out of the palette: with blue and orange fixed, aqua is the only
   hue that clears CVD separation against them in both modes. Solid, not a translucent
   tint: alpha over a near-black ground slides toward grey, which is why this read as
   washed out however high the figure went. */
.rmsband { fill: var(--bandfill); }
/* Only the axis line takes the step above the prose: it names what the whole chart
   is, and it was the label being squinted at. The tick numbers stay at body size —
   there are twenty of them ringing the plot, and a step up crowds what they annotate. */
.ytick { fill: var(--muted); font-size: var(--t); text-anchor: end;
  font-variant-numeric: tabular-nums; }
.axtitle { fill: var(--muted); font-size: var(--t-lg); text-anchor: middle; }
.paneltitle { fill: var(--ink2); font-size: var(--t-lg); font-weight: 600; text-anchor: middle; }

/* paths, not rects: a path grows from the zero line in either direction without
   sign juggling. Square-capped at both ends, and no stroke — the pair meets edge
   to edge, so a stroke would have each stick bleed half its width into the other */
.bar { stroke: none; }
.bar.s1 { fill: var(--s1); }
.bar.s2 { fill: var(--s2); }

.xtick { fill: var(--ink2); font-size: var(--t); text-anchor: middle;
  font-variant-numeric: tabular-nums; }
/* sorted, every n is labelled, so this is the one place a tick has to fit inside
   an 18px band */
.xtick.dense { font-size: var(--t-xs); }
.xtick.on { fill: var(--ink); font-weight: 700; }

.hit { fill: transparent; cursor: pointer; }
.hit.hot { fill: var(--wash); }
.selband { fill: var(--selwash); }
.dim { color: var(--muted); }

/* the live readout, on its own line under the controls: the division, the
   compromise the ribbon draws, and what it costs each harmonic counted */
.readout {
  display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center;
  gap: 7px 18px;
  margin: 12px 0 4px;
  font-size: var(--t-sm); color: var(--ink2);
  font-variant-numeric: tabular-nums;
}
.pn-n, .pn-c { font-size: var(--t-lg); font-weight: 650; line-height: 1;
  letter-spacing: -0.02em; color: var(--ink); white-space: nowrap; }
.pn-n .suf, .pn-c .suf { font-size: var(--t-sm); font-weight: 500; color: var(--ink2);
  letter-spacing: 0; }
.pn-c .suf { margin-left: 7px; }
.pn-c .sw { display: inline-block; vertical-align: 0; margin-right: 7px; }

/* plain inline, not inline-flex: a flex box takes its baseline from its first
   item, which here is the empty swatch — so the division and the text beside it
   landed on different baselines and read as mis-centred */
.off { white-space: nowrap; }
.off .sw { display: inline-block; vertical-align: -1px; margin-right: 7px; }
.off b { margin-left: 7px; font-weight: 650; color: var(--ink); }

.playbtn {
  font: inherit; font-size: var(--t-sm); padding: 7px 15px; cursor: pointer;
  background: transparent; color: var(--ink);
  border: 1px solid var(--hair); border-radius: 999px;
}
.playbtn:hover { background: var(--wash); }
/* these latch now: play holds the chord, and "just 4:5:6" is a standing choice
   of tuning rather than a one-shot, so both have to show when they are on */
.playbtn.on { border-color: var(--sum); background: var(--wash); font-weight: 600; }

/* Unpressed, it breathes a ring every few seconds so the eye finds it — the same
   nudge Overtones wears, and there for the same reason: the figure above is
   already drawn and already still, so nothing about it announces that it also
   sounds. In --sum, the ink this page spends on interaction (the latch border,
   the band strip's focus ring), rather than on a hue — the two series colours are
   spoken for, and a third would read as a third series. */
.playbtn.nudge { animation: nudge 2.8s ease-out infinite; }

@keyframes nudge {
  0%        { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sum) 40%, transparent); }
  50%, 100% { box-shadow: 0 0 0 10px transparent; }
}

@media (prefers-reduced-motion: reduce) {
  .playbtn.nudge { animation: none; }
}

/* play · timbre · one note or chord — three groups, spread */
.toolbar.three { justify-content: space-between; }
.wrow { margin-bottom: 9px; }
.wlab { display: block; font-size: var(--t-sm); color: var(--muted); margin-bottom: 2px; }
.wcanvas { display: block; width: 100%; }
/* the band strip IS the timbre control: drag it */
.wcanvas.grab {
  cursor: grab; touch-action: none;   /* a vertical drag must not scroll the page */
  -webkit-user-select: none; user-select: none;
}
.wcanvas.grab:active { cursor: grabbing; }
.wcanvas.grab:focus { outline: none; }
.wcanvas.grab:focus-visible { outline: 2px solid var(--sum); outline-offset: -2px; }

.tiphost { position: relative; display: inline-flex; }
.tiphost::after {
  content: attr(data-tip);
  position: absolute; bottom: calc(100% + 9px); right: 0; left: auto;
  width: 320px; padding: 10px 12px;
  background: var(--surface); color: var(--ink2);
  border: 1px solid var(--hair); border-radius: 9px;
  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.20);
  font-size: var(--t-sm); line-height: 1.45; text-transform: none; letter-spacing: 0;
  opacity: 0; visibility: hidden; transition: opacity 0.12s;
  pointer-events: none; z-index: 5;
}
.tiphost:hover::after, .tiphost:focus-within::after { opacity: 1; visibility: visible; }

/* both parts wear the same dress: a rule above, then the content */
.sect { margin: 22px 0 0; padding-top: 15px; border-top: 1px solid var(--hair); }
```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "description": "Why split the octave into 12 notes? An interactive explainer.",
  "dependencies": {
    "react": "^19.2.7",
    "react-dom": "^19.2.7"
  }
}
```