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."
}
```