Overtones

Draw the harmonic series to hear the timbre changes.

---
format: typebulb/v1
name: Overtones
---

**code.tsx**

```tsx
import { App, Component, button, canvas, div, formField, inputRange, path, polygon, rect, svg } from "domeleon"

/** Every band count shares one pool of partials, so switching resolution is non-destructive. */
const MAX_HARMONICS = 64
const BAND_COUNTS = [16, 64]

type PresetName = "Sine" | "Sawtooth" | "Square" | "Triangle"

/**
 * Presets return a *signed* weight: the magnitude sets the band's level, the sign its
 * phase. Triangle needs the alternation — without it the 1/n² series collapses to a sine.
 */
const presets: Record<PresetName, (n: number) => number> = {
  Sine:     n => (n === 1 ? 1 : 0),
  Sawtooth: n => 1 / n,
  Square:   n => (n % 2 === 1 ? 1 / n : 0),
  Triangle: n => (n % 2 === 1 ? Math.pow(-1, (n - 1) / 2) / (n * n) : 0)
}

const presetNames = Object.keys(presets) as PresetName[]

// fresh props per call, and sized by attribute so the icon never depends on CSS landing
const iconBox = () => ({ viewBox: "0 0 24 24", width: 18, height: 18, className: "icon" })
const strokeIcon = () => ({
  fill: "none", stroke: "currentColor", strokeWidth: 1.7,
  strokeLinecap: "round", strokeLinejoin: "round"
})

const playIcon = () => svg(iconBox(), polygon({ points: "6.5,4 20,12 6.5,20", fill: "currentColor" }))
const stopIcon = () => svg(iconBox(), rect({ x: 5.5, y: 5.5, width: 13, height: 13, rx: 2, fill: "currentColor" }))

/** Freeze = frost the scrolling wave; animate = set it moving again. */
const freezeIcon = () => svg(iconBox(), path({
  ...strokeIcon(),
  d: "M12 4V20 M5.1 8L18.9 16 M5.1 16L18.9 8 M9.5 6.2L12 8.7L14.5 6.2 M9.5 17.8L12 15.3L14.5 17.8"
}))
const waveIcon = () => svg(iconBox(), path({ ...strokeIcon(), d: "M2.5 12q2.4-8 4.8 0t4.8 0t4.8 0t4.8 0" }))

const DEGREES = ["unison", "min 2nd", "maj 2nd", "min 3rd", "maj 3rd", "4th",
  "tritone", "5th", "min 6th", "maj 6th", "min 7th", "maj 7th"]

/**
 * Where harmonic n lands on the keyboard: the nearest 12-TET interval above the
 * fundamental, plus how far off it is. Derived rather than tabulated, so it holds
 * for all 64 partials — H7 is a minor 7th 31 cents flat, and says so.
 */
function interval(n: number) {
  const semis = 12 * Math.log2(n)
  const nearest = Math.round(semis)
  const cents = Math.round((semis - nearest) * 100)
  const octaves = Math.floor(nearest / 12)
  const degree = nearest % 12
  const tilde = Math.abs(cents) >= 15 ? "~" : ""

  let name: string
  if (n === 1) name = "fundamental"
  else if (degree === 0) name = octaves === 1 ? "octave" : `${octaves} octaves`
  else if (octaves === 0) name = `${tilde}${DEGREES[degree]}`
  else name = `${octaves} oct + ${tilde}${DEGREES[degree]}`

  return { name, cents }
}

const centsLabel = (cents: number) => `${cents > 0 ? "+" : "−"}${Math.abs(cents)}¢`

const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v)

/** A slider's filled proportion — WebKit won't fill a custom track for us. */
const fillPct = (v: number, min: number, max: number) =>
  `${clamp(((v - min) / (max - min)) * 100, 0, 100)}%`

const NOTES = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]

const noteName = (freq: number) => {
  const midi = Math.round(69 + 12 * Math.log2(freq / 440))
  return `${NOTES[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`
}

const isOctave = (n: number) => (n & (n - 1)) === 0

function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
  const rr = Math.max(0, Math.min(r, w / 2, h / 2))
  ctx.beginPath()
  ctx.moveTo(x + rr, y)
  ctx.arcTo(x + w, y, x + w, y + h, rr)
  ctx.arcTo(x + w, y + h, x, y + h, rr)
  ctx.arcTo(x, y + h, x, y, rr)
  ctx.arcTo(x, y, x + w, y, rr)
  ctx.closePath()
}

class Harmonic {
  constructor(readonly n: number, public amplitude: number) {}

  /** +1 in phase, -1 inverted. Inaudible on its own; it reshapes the composite wave. */
  sign: 1 | -1 = 1

  get gain() { return this.amplitude / 100 }
  get signedGain() { return this.sign * this.amplitude / 100 }

  freq(fundamental: number) { return fundamental * this.n }
}

/** A canvas plus its logical (CSS-pixel) size; the backing store is DPR-scaled. */
interface Surface {
  el?: HTMLCanvasElement
  ctx?: CanvasRenderingContext2D
  w: number
  h: number
}

const WAVE_H = 240
const SPEC_H = 260

class Spectrum extends Component {
  harmonics = Array.from({ length: MAX_HARMONICS }, (_, i) =>
    new Harmonic(i + 1, Math.round(presets.Sawtooth(i + 1) * 100)))

  /** How many partials are live: the rest keep their levels but are silent and hidden. */
  count = 16
  fundamental = 220
  masterVolume = 30
  playing = false
  /** Cleared on the first press: until then the play button pulses to ask for it. */
  everPlayed = false
  /** Starts frozen: the composite shape reads before it starts moving. */
  paused = true
  speed = 0.04
  /** The preset the current mix matches, or null once it has been sculpted by hand. */
  preset: PresetName | null = "Sawtooth"
  offset = 0

  private wave: Surface = { w: 0, h: WAVE_H }
  private spec: Surface = { w: 0, h: SPEC_H }
  private animId: number | null = null
  private ro?: ResizeObserver

  // band editing
  private hoverIdx: number | null = null
  private dragging = false
  private lastIdx: number | null = null
  private lastAmp = 0
  /** Slider wrappers, so each track can be told how much of it is filled. */
  private sliderEls: Array<HTMLElement | undefined> = []

  private audioCtx?: AudioContext
  private master?: GainNode
  private sources: Array<{ osc: OscillatorNode; gain: GainNode }> = []

  // ---------- colour ----------

  /** The rainbow always spans the live band range, whatever the count. */
  private hue(n: number) { return (n - 1) * 360 / this.count }
  private colorOf(n: number) { return `hsl(${this.hue(n)}, 78%, 55%)` }
  private brightOf(n: number) { return `hsl(${this.hue(n)}, 92%, 70%)` }

  // ---------- math ----------

  /** Normalized sample of the composite wave at phase (0..1 of one period). */
  sampleAt(phase: number): number {
    let sum = 0
    for (let i = 0; i < this.count; i++) {
      const h = this.harmonics[i]
      if (h.amplitude === 0) continue
      sum += h.signedGain * Math.sin(2 * Math.PI * h.n * phase)
    }
    return sum
  }

  get totalGain() {
    let sum = 0
    for (let i = 0; i < this.count; i++) sum += this.harmonics[i].gain
    return sum || 1
  }

  // ---------- audio ----------

  private ensureAudio() {
    if (this.audioCtx) return
    const ctx = new AudioContext()
    this.audioCtx = ctx
    this.master = ctx.createGain()
    this.master.gain.value = 0
    this.master.connect(ctx.destination)

    // one oscillator per slot in the pool, so changing the band count never rebuilds the graph
    for (const h of this.harmonics) {
      const osc = ctx.createOscillator()
      const gain = ctx.createGain()
      osc.type = "sine"
      osc.frequency.value = h.freq(this.fundamental)
      gain.gain.value = 0
      osc.connect(gain)
      gain.connect(this.master)
      osc.start()
      this.sources.push({ osc, gain })
    }
    this.updateAudio()
  }

  updateAudio() {
    const ctx = this.audioCtx
    if (!ctx || !this.master) return
    const t = ctx.currentTime
    // attenuate to stay clip-free, but never boost: normalizing a near-empty mix
    // upward would turn a lone faint high partial into a full-volume spike
    const norm = 1 / Math.max(1, this.totalGain)
    const nyquist = ctx.sampleRate / 2

    this.sources.forEach(({ osc, gain }, i) => {
      const h = this.harmonics[i]
      const freq = h.freq(this.fundamental)
      const live = i < this.count && freq < nyquist
      osc.frequency.setTargetAtTime(Math.min(freq, nyquist), t, 0.01)
      gain.gain.setTargetAtTime(live ? h.signedGain * norm : 0, t, 0.02)
    })

    const target = this.playing ? (this.masterVolume / 100) * 0.5 : 0
    this.master.gain.setTargetAtTime(target, t, 0.03)
  }

  override onUpdated() {
    this.updateAudio()
  }

  /** Runs after every DOM patch: a style object can't carry a `--custom` property. */
  override onRendered() {
    this.paintSliders()
  }

  private paintSliders() {
    const fills = [
      fillPct(this.fundamental, 80, 440),
      fillPct(this.masterVolume, 0, 100),
      fillPct(this.speed, 0, 0.2)
    ]
    fills.forEach((f, i) => this.sliderEls[i]?.style.setProperty("--fill", f))
  }

  private mountSlider(i: number, el: Element) {
    this.sliderEls[i] = el as HTMLElement
    this.paintSliders()
    return () => { this.sliderEls[i] = undefined }
  }

  togglePlay() {
    this.ensureAudio()
    this.audioCtx?.resume()
    this.playing = !this.playing
    this.everPlayed = true
    this.update()
  }

  // ---------- band editing ----------

  /** Column geometry of the spectrum editor, shared by drawing and hit-testing. */
  private geom() {
    const { w, h } = this.spec
    const padX = 12
    const top = 38
    const bottom = h - 30
    const slot = (w - padX * 2) / this.count
    const trackW = Math.max(2, Math.min(slot - 1.5, slot * 0.78))
    return { w, h, padX, top, bottom, slot, trackW, radius: Math.min(5, trackW / 3) }
  }

  private posAt(e: PointerEvent) {
    const el = this.spec.el
    if (!el) return null
    const rect = el.getBoundingClientRect()
    if (!rect.width || !rect.height) return null

    const g = this.geom()
    const x = (e.clientX - rect.left) * (g.w / rect.width)
    const y = (e.clientY - rect.top) * (g.h / rect.height)

    const idx = clamp(Math.floor((x - g.padX) / g.slot), 0, this.count - 1)
    let amp = clamp(Math.round(((g.bottom - y) / (g.bottom - g.top)) * 100), 0, 100)
    if (amp <= 2) amp = 0
    if (amp >= 98) amp = 100
    return { idx, amp }
  }

  /** Set the band under the pointer, filling in any columns a fast sweep jumped over. */
  private paint(e: PointerEvent) {
    const pos = this.posAt(e)
    if (!pos) return
    const { idx, amp } = pos

    if (this.lastIdx !== null && this.lastIdx !== idx) {
      const step = idx > this.lastIdx ? 1 : -1
      const span = Math.abs(idx - this.lastIdx)
      for (let k = 1; k < span; k++)
        this.harmonics[this.lastIdx + step * k].amplitude =
          Math.round(this.lastAmp + (amp - this.lastAmp) * (k / span))
    }

    this.harmonics[idx].amplitude = amp
    this.lastIdx = idx
    this.lastAmp = amp
    this.hoverIdx = idx
    this.updateAudio() // the rAF loop repaints; no vdom pass needed mid-drag
  }

  private onDown = (e: PointerEvent) => {
    e.preventDefault()
    const hit = this.posAt(e)
    if (!hit) return
    if (e.altKey) { this.flip(hit.idx); return } // alt-click inverts rather than sets a level
    this.spec.el?.setPointerCapture(e.pointerId)
    if (this.spec.el) this.spec.el.style.cursor = "grabbing"
    this.dragging = true
    this.lastIdx = null
    this.paint(e)
    this.preset = null
    this.update() // one vdom pass: the preset chips drop their highlight
  }

  private onMove = (e: PointerEvent) => {
    if (this.dragging) { this.paint(e); return }
    this.hoverIdx = this.posAt(e)?.idx ?? null
  }

  private onUp = () => {
    if (!this.dragging) return
    this.dragging = false
    this.lastIdx = null
    if (this.spec.el) this.spec.el.style.cursor = ""
  }

  private onLeave = () => {
    if (!this.dragging) this.hoverIdx = null
  }

  private flip(idx: number) {
    const h = this.harmonics[idx]
    h.sign = h.sign === 1 ? -1 : 1
    this.hoverIdx = idx
    this.preset = null
    this.update()
  }

  /** Keyboard equivalent of the drag gesture: ←/→ pick a band, ↑/↓ set its level. */
  private onKey = (e: KeyboardEvent) => {
    const cur = this.hoverIdx ?? 0
    const move = (idx: number) => { this.hoverIdx = clamp(idx, 0, this.count - 1) }
    const set = (amp: number) => {
      this.hoverIdx = cur
      this.harmonics[cur].amplitude = clamp(Math.round(amp), 0, 100)
      this.preset = null
      this.update()
    }
    switch (e.key) {
      case "ArrowLeft":  move(cur - 1); break
      case "ArrowRight": move(cur + 1); break
      case "ArrowUp":    set(this.harmonics[cur].amplitude + (e.shiftKey ? 1 : 5)); break
      case "ArrowDown":  set(this.harmonics[cur].amplitude - (e.shiftKey ? 1 : 5)); break
      case "Home":       set(0); break
      case "End":        set(100); break
      case "i": case "I": case "-": this.flip(cur); break
      default: return
    }
    e.preventDefault()
  }

  // ---------- drawing ----------

  private cssVar(name: string, fallback: string) {
    const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
    return v || fallback
  }

  private drawWave() {
    const { ctx, w, h } = this.wave
    if (!ctx || !w) return
    const mid = h / 2
    ctx.clearRect(0, 0, w, h)

    ctx.strokeStyle = this.cssVar("--grid-color", "#888")
    ctx.lineWidth = 0.5
    ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(w, mid); ctx.stroke()

    const cycles = 2
    const norm = 1 / this.totalGain
    const amp = mid * 0.85
    const focus = this.hoverIdx

    const component = (hm: Harmonic) => {
      ctx.beginPath()
      for (let x = 0; x <= w; x++) {
        const phase = (x / w) * cycles + this.offset
        const y = mid - hm.signedGain * norm * amp * Math.sin(2 * Math.PI * hm.n * phase)
        x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
      }
      ctx.stroke()
    }

    // faint individual partials; the band under the pointer steps forward
    for (let i = 0; i < this.count; i++) {
      const hm = this.harmonics[i]
      if (i === focus || hm.gain * norm * amp < 0.4) continue // sub-pixel partials draw as noise
      ctx.globalAlpha = focus === null ? 0.35 : 0.12
      ctx.lineWidth = 1
      ctx.strokeStyle = this.colorOf(hm.n)
      component(hm)
    }
    ctx.globalAlpha = 1

    // composite
    ctx.strokeStyle = this.cssVar("--sum-color", "#00c853")
    ctx.lineWidth = 2.5
    ctx.beginPath()
    for (let x = 0; x <= w; x++) {
      const phase = (x / w) * cycles + this.offset
      const y = mid - this.sampleAt(phase) * norm * amp
      x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
    }
    ctx.stroke()

    if (focus !== null) {
      const hm = this.harmonics[focus]
      if (hm.gain > 0) {
        ctx.strokeStyle = this.colorOf(hm.n)
        ctx.lineWidth = 2
        component(hm)
      }
    }
  }

  private drawSpectrum() {
    const { ctx, w, h } = this.spec
    if (!ctx || !w) return
    ctx.clearRect(0, 0, w, h)

    const g = this.geom()
    const plotH = g.bottom - g.top
    const grid = this.cssVar("--grid-color", "#888")
    const text = this.cssVar("--text-color", "#333")
    const muted = this.cssVar("--muted-color", "#777")
    const focus = this.hoverIdx

    // horizontal guides
    ctx.strokeStyle = grid
    ctx.lineWidth = 0.5
    ctx.globalAlpha = 0.35
    for (const frac of [0.25, 0.5, 0.75, 1]) {
      const y = g.bottom - plotH * frac
      ctx.beginPath(); ctx.moveTo(g.padX, y); ctx.lineTo(w - g.padX, y); ctx.stroke()
    }
    ctx.globalAlpha = 1
    ctx.beginPath(); ctx.moveTo(g.padX, g.bottom); ctx.lineTo(w - g.padX, g.bottom); ctx.stroke()

    // dense band counts can't label every column: thin out, but never drop an octave
    const labelStep = g.slot >= 30 ? 1 : g.slot >= 20 ? 2 : g.slot >= 10 ? 4 : 8
    ctx.font = "10px sans-serif"
    ctx.textAlign = "center"

    // the bands: track (the drag target) + filled bar + grab cap
    for (let i = 0; i < this.count; i++) {
      const hm = this.harmonics[i]
      const cx = g.padX + (i + 0.5) * g.slot
      const x = cx - g.trackW / 2
      const hot = i === focus

      ctx.globalAlpha = hot ? 0.16 : 0.07
      ctx.fillStyle = this.colorOf(hm.n)
      roundRect(ctx, x, g.top, g.trackW, plotH, g.radius)
      ctx.fill()
      ctx.globalAlpha = 1

      const barH = (hm.amplitude / 100) * plotH
      if (barH > 0.5) {
        const grad = ctx.createLinearGradient(0, g.bottom - barH, 0, g.bottom)
        grad.addColorStop(0, `hsl(${this.hue(hm.n)}, 82%, 60%)`)
        grad.addColorStop(1, `hsl(${this.hue(hm.n)}, 72%, 42%)`)
        ctx.fillStyle = grad
        roundRect(ctx, x, g.bottom - barH, g.trackW, barH, Math.min(4, g.radius))
        ctx.fill()
      }

      // cap: always drawn, so a silent partial still shows a handle to grab
      const capY = g.bottom - Math.max(barH, 3)
      ctx.fillStyle = hot ? this.brightOf(hm.n) : this.colorOf(hm.n)
      if (hot) { ctx.shadowColor = this.colorOf(hm.n); ctx.shadowBlur = 10 }
      roundRect(ctx, x, capY, g.trackW, 3, 1.5)
      ctx.fill()
      ctx.shadowBlur = 0

      // inverted partials carry a minus tick below the axis
      if (hm.sign === -1) {
        const tickW = Math.min(10, g.trackW)
        ctx.fillStyle = this.colorOf(hm.n)
        roundRect(ctx, cx - tickW / 2, g.bottom + 5, tickW, 2, 1)
        ctx.fill()
      }

      if (hot || hm.n === 1 || hm.n % labelStep === 0 || isOctave(hm.n)) {
        ctx.fillStyle = hot ? text : isOctave(hm.n) ? muted : grid
        ctx.font = `${hot ? "bold " : ""}10px sans-serif`
        ctx.fillText(String(hm.n), cx, h - 12)
      }
    }

    // readout strip
    ctx.textAlign = "left"
    if (focus === null) {
      ctx.fillStyle = muted
      ctx.font = "14px sans-serif"
      ctx.fillText(
        w >= 820 ? "Drag the bands to sculpt the timbre. Sweep sideways to draw a spectrum, alt-click to invert a partial."
        : w >= 600 ? "Drag the bands to sculpt the timbre. Sweep sideways to draw a whole spectrum."
        : "Drag the bands to sculpt the timbre.", g.padX, 23)
    } else {
      const hm = this.harmonics[focus]
      const { name, cents } = interval(hm.n)
      const detail = w >= 640 && Math.abs(cents) >= 5
        ? `${name} ${centsLabel(cents)} · ${Math.round(hm.freq(this.fundamental))} Hz`
        : `${name} · ${Math.round(hm.freq(this.fundamental))} Hz`

      ctx.fillStyle = this.colorOf(hm.n)
      roundRect(ctx, g.padX, 13, 10, 10, 2)
      ctx.fill()
      ctx.fillStyle = text
      ctx.font = "bold 14px sans-serif"
      ctx.fillText(`H${hm.n}`, g.padX + 18, 23)
      const tagW = ctx.measureText(`H${hm.n}`).width
      ctx.fillStyle = muted
      ctx.font = "14px sans-serif"
      ctx.fillText(detail, g.padX + 18 + tagW + 14, 23)
      ctx.fillStyle = text
      ctx.font = "bold 14px sans-serif"
      ctx.textAlign = "right"
      ctx.fillText(`${hm.sign === -1 && hm.amplitude > 0 ? "−" : ""}${hm.amplitude}%`, w - g.padX, 23)
    }
  }

  private animate = () => {
    if (!this.paused) this.offset += this.speed
    this.drawWave()
    this.drawSpectrum()
    this.animId = requestAnimationFrame(this.animate)
  }

  private fit(s: Surface) {
    const el = s.el
    if (!el?.parentElement) return
    const dpr = Math.min(2, window.devicePixelRatio || 1)
    s.w = Math.max(220, Math.floor(el.parentElement.getBoundingClientRect().width))
    el.width = Math.round(s.w * dpr)
    el.height = Math.round(s.h * dpr)
    el.style.height = `${s.h}px`
    s.ctx?.setTransform(dpr, 0, 0, dpr, 0, 0) // sizing the backing store clears the transform
  }

  private mountCanvas(el: HTMLCanvasElement, which: "wave" | "spec") {
    const s = which === "wave" ? this.wave : this.spec
    s.el = el
    s.ctx = el.getContext("2d")!
    this.fit(s)

    if (!this.ro) this.ro = new ResizeObserver(() => { this.fit(this.wave); this.fit(this.spec) })
    if (el.parentElement) this.ro.observe(el.parentElement)

    if (this.animId === null) this.animate()

    return () => {
      s.el = undefined
      s.ctx = undefined
      if (!this.wave.el && !this.spec.el) this.teardown()
    }
  }

  private teardown() {
    if (this.animId !== null) { cancelAnimationFrame(this.animId); this.animId = null }
    this.ro?.disconnect(); this.ro = undefined
    this.sources.forEach(({ osc }) => { try { osc.stop() } catch {} ; osc.disconnect() })
    this.sources = []
    this.audioCtx?.close()
    this.audioCtx = undefined
    this.master = undefined
  }

  // ---------- actions ----------

  /** Levels above the new count are kept, not cleared — switching back restores them. */
  setCount(count: number) {
    this.count = count
    if (this.hoverIdx !== null) this.hoverIdx = Math.min(this.hoverIdx, count - 1)
    this.update()
  }

  applyPreset(name: PresetName) {
    const fn = presets[name]
    for (const h of this.harmonics) {
      const weight = fn(h.n)
      h.sign = weight < 0 ? -1 : 1
      h.amplitude = Math.round(Math.min(1, Math.abs(weight)) * 100)
    }
    this.preset = name
    this.update()
  }

  // ---------- view ----------

  view() {
    return div({ className: "container" },
      div({ className: "header" },
        div({ className: "title" }, "Overtones"),
        div({ className: "subtitle" },
          "A note is a stack of sine waves: change the mix and you change the timbre.")
      ),

      div({ className: "canvas-box" },
        canvas({
          className: "viz-canvas",
          ariaLabel: "waveform",
          onMounted: el => this.mountCanvas(el as HTMLCanvasElement, "wave")
        })
      ),

      div({ className: "canvas-box" },
        canvas({
          className: "viz-canvas spectrum-canvas",
          ariaLabel: "harmonic spectrum: drag a band to set its level, or use the arrow keys",
          tabIndex: 0,
          onPointerDown: (e: PointerEvent) => this.onDown(e),
          onPointerMove: (e: PointerEvent) => this.onMove(e),
          onPointerUp: () => this.onUp(),
          onPointerCancel: () => this.onUp(),
          onPointerLeave: () => this.onLeave(),
          onKeyDown: (e: KeyboardEvent) => this.onKey(e),
          onMounted: el => this.mountCanvas(el as HTMLCanvasElement, "spec")
        })
      ),

      div({ className: "controls" },
        div({ className: "row groups" },
          div({ className: "chips-field" },
            div({ className: "chips-label" }, "Play"),
            div({ className: "chips" },
              button({
                className: `btn primary icon-btn${this.everPlayed ? "" : " nudge"}`,
                ariaLabel: this.playing ? "Stop" : "Play",
                title: this.playing ? "Stop" : "Play",
                onClick: () => this.togglePlay()
              }, this.playing ? stopIcon() : playIcon()),
              button({
                className: "btn icon-btn",
                ariaLabel: this.paused ? "Animate" : "Freeze",
                title: this.paused ? "Animate the wave" : "Freeze the wave",
                onClick: () => { this.paused = !this.paused; this.update() }
              }, this.paused ? waveIcon() : freezeIcon())
            )
          ),
          div({ className: "chips-field" },
            div({ className: "chips-label" }, "Timbre"),
            div({ className: "chips" },
              ...presetNames.map(name =>
                button({
                  className: `chip${this.preset === name ? " on" : ""}`,
                  onClick: () => this.applyPreset(name)
                }, name))
            )
          ),
          div({ className: "chips-field" },
            div({ className: "chips-label" }, "Bands"),
            div({ className: "chips" },
              ...BAND_COUNTS.map(c =>
                button({
                  className: `chip${this.count === c ? " on" : ""}`,
                  onClick: () => this.setCount(c)
                }, String(c)))
            )
          )
        ),

        div({ className: "row sliders" },
          div({ className: "slider", onMounted: el => this.mountSlider(0, el) },
            formField({
              target: this,
              inputFn: inputRange,
              prop: () => this.fundamental,
              label: `Fundamental: ${Math.round(this.fundamental)} Hz · ${noteName(this.fundamental)}`,
              inputProps: { attrs: { min: 80, max: 440, step: 1 } }
            })),
          div({ className: "slider", onMounted: el => this.mountSlider(1, el) },
            formField({
              target: this,
              inputFn: inputRange,
              prop: () => this.masterVolume,
              label: `Volume: ${this.masterVolume}%`,
              inputProps: { attrs: { min: 0, max: 100, step: 1 } }
            })),
          div({ className: "slider", onMounted: el => this.mountSlider(2, el) },
            formField({
              target: this,
              inputFn: inputRange,
              prop: () => this.speed,
              label: `Scroll speed: ${Math.round(this.speed / 0.2 * 100)}%`,
              inputProps: { attrs: { min: 0, max: 0.2, step: 0.005 } }
            }))
        )
      )
    )
  }
}

new App({ root: new Spectrum(), id: "app" })
```
**styles.css**

```css
* {
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  box-sizing: border-box;
}

:root {
  --border-color: #ccc;
  --bg-color: #fafafa;
  --panel-bg: #fff;
  --grid-color: #999;
  --text-color: #222;
  --muted-color: #777;
  --control-bg: #f2f2f2;
  --sum-color: #00a844;
}

html[data-theme="dark"] {
  --border-color: #444;
  --bg-color: #161616;
  --panel-bg: #1f1f1f;
  --grid-color: #555;
  --text-color: #e6e6e6;
  --muted-color: #999;
  --control-bg: #262626;
  --sum-color: #3ddc84;
}

html[data-theme="light"] {
  --border-color: #ccc;
  --bg-color: #fafafa;
  --panel-bg: #fff;
  --grid-color: #999;
  --text-color: #222;
  --muted-color: #777;
  --control-bg: #f2f2f2;
  --sum-color: #00a844;
}

/* the background belongs to body: on .container it stops at max-width and leaves gutters */
body {
  margin: 0;
  color: var(--text-color);
  background: var(--bg-color);
}

.container {
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding: 20px;
  max-width: 1000px;
  margin: 0 auto;
}

.header { text-align: center; }
.title { font-size: 22px; font-weight: 700; }
.subtitle { font-size: 16px; color: var(--muted-color); margin-top: 6px; }

.canvas-box {
  background: var(--panel-bg);
  border: 1px solid var(--border-color);
  border-radius: 6px;
  padding: 0;
  overflow: hidden;
}

.viz-canvas { display: block; width: 100%; }

/* the spectrum canvas IS the harmonic mixer: drag its bands */
.spectrum-canvas {
  cursor: grab;                /* a hand reads as "slide me", in any direction */
  touch-action: none;          /* vertical drags must not scroll the page */
  -webkit-user-select: none;
  user-select: none;
}

.spectrum-canvas:focus { outline: none; }
.spectrum-canvas:focus-visible { outline: 2px solid var(--sum-color); outline-offset: -2px; }

.controls {
  display: flex;
  flex-direction: column;
  gap: 16px;
  background: var(--panel-bg);
  border: 1px solid var(--border-color);
  border-radius: 6px;
  padding: 14px;
}

.row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 16px;
}

/* three across or one across: a 2 + 1 orphan row is never right */
.row.sliders {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

/* each group keeps its natural width and the row centres them, so a narrow
   window breaks between groups instead of through one */
.row.groups { justify-content: center; column-gap: 24px; }
.row.groups > * { flex: 0 1 auto; min-width: 0; }

label {
  display: block;
  font-weight: 500;
  font-size: 12px;
  color: var(--muted-color);
  text-align: center;
}

/* range inputs, dressed to match the rest: hairline track, ringed thumb, themed fill */
/* the fill lives on the wrapper: declared on the input itself it would shadow the ref-set value */
.slider { --fill: 50%; }

input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 16px;
  margin: 7px 0 0;
  background: transparent;
  cursor: pointer;
}

input[type="range"]::-webkit-slider-runnable-track {
  height: 5px;
  border-radius: 3px;
  background: linear-gradient(to right, var(--sum-color) var(--fill), var(--border-color) var(--fill));
}

input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  box-sizing: border-box;
  width: 13px;
  height: 13px;
  margin-top: -4px;
  border-radius: 50%;
  background: var(--panel-bg);
  border: 2px solid var(--sum-color);
}

input[type="range"]::-moz-range-track {
  height: 5px;
  border-radius: 3px;
  background: var(--border-color);
}

input[type="range"]::-moz-range-progress {
  height: 5px;
  border-radius: 3px;
  background: var(--sum-color);
}

input[type="range"]::-moz-range-thumb {
  box-sizing: border-box;
  width: 13px;
  height: 13px;
  border-radius: 50%;
  background: var(--panel-bg);
  border: 2px solid var(--sum-color);
}

input[type="range"]:focus { outline: none; }
input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px color-mix(in srgb, var(--sum-color) 35%, transparent); }
input[type="range"]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px color-mix(in srgb, var(--sum-color) 35%, transparent); }
.btn {
  padding: 7px 14px;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  color: var(--text-color);
  background: var(--panel-bg);
  border: 1px solid var(--border-color);
  border-radius: 4px;
}

.btn:hover { border-color: var(--text-color); }

.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 32px;
  padding: 0;
}

.icon { display: block; width: 18px; height: 18px; }

/* the one call to action, but pitched level with the rest of the chrome:
   a tinted outline like an active chip, not a slab of colour */
.btn.primary {
  color: var(--sum-color);
  background: color-mix(in srgb, var(--sum-color) 10%, var(--panel-bg));
  border-color: var(--sum-color);
  box-shadow: inset 0 0 0 1px var(--sum-color);
}

.btn.primary:hover {
  border-color: var(--sum-color);
  background: color-mix(in srgb, var(--sum-color) 20%, var(--panel-bg));
}

/* unpressed, it breathes a ring every few seconds so the eye finds it */
.btn.primary.nudge { animation: nudge 2.8s ease-out infinite; }

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

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

.chips-label {
  font-weight: 500;
  font-size: 12px;
  color: var(--muted-color);
  margin-bottom: 4px;
  text-align: center;
}

.chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }

.chip {
  padding: 5px 11px;
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  color: var(--muted-color);
  background: var(--panel-bg);
  border: 1px solid var(--border-color);
  border-radius: 999px;
}

.chip:hover { color: var(--text-color); border-color: var(--text-color); }

.chip.on {
  color: var(--text-color);
  border-color: var(--sum-color);
  box-shadow: inset 0 0 0 1px var(--sum-color);
}

@media (max-width: 600px) {
  .container { padding: 12px; }
}

@media (max-width: 660px) {
  .row.sliders { grid-template-columns: minmax(0, 1fr); }
}
```
**index.html**

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

```json
{
  "dependencies": {
    "domeleon": "^0.6.6"
  },
  "description": "Draw the harmonic series to hear the timbre changes."
}
```

Markdown source · More bulbs by antypica · Typebulb home