Risan Bagja

tutorial··20 min read

Making the GitHub Heatmap Sing

Turning hover states on the GitHub heatmap into oscillators, envelopes, reverb, and a tiny sequencer that plays your year — with Web Audio, no library.

Last time, the heatmap just sat there — a grid of rectangles, a color per contribution level, nothing you could do but look at it. It bugged me. Every cell already carries a day, a weekday, a count. That’s basically a note, a pitch, and a velocity. So I wired the grid up to the Web Audio API and turned a year of commits into something you can hover, tap, and play back like a sequencer.

This post is the synth half: how a day becomes a note, how a note gets its sound, and how the whole thing turns into a tiny instrument you play by moving your mouse across a calendar. Every section below has a small working version you can click — real oscillators, running in your browser right now.

Heads up: every demo below needs a click before it can make sound. Browsers won’t let a page play audio until a user gesture unlocks it, so the first press of any button also creates the AudioContext.

From a date to a note

Each contribution day already has what a note needs: weekday picks the row (so it picks the pitch), and contributionLevel picks how loud. The only new idea here is a scale — a short list of steps that keeps every note sounding like it belongs together, picked by mood:

Terminal window
const SCALES: Record<MoodId, number[]> = {
bright: [0, 2, 4, 7, 9],
moody: [0, 3, 5, 7, 10],
mystic: [0, 2, 3, 7, 8],
};
const ROOT_MIDI = 48;
const FIRST_SCALE_STEP = 5;
const CHORD_SHIFTS = [0, 2, -1, 1]; // nudges the key centre, cycling every 4 months
function noteFrequency(mood: MoodId, weekday: number, chord: number) {
const scale = SCALES[mood];
const step = FIRST_SCALE_STEP + (6 - weekday) + CHORD_SHIFTS[chord];
const octave = Math.floor(step / scale.length);
const midi = ROOT_MIDI + 12 * octave + scale[step - octave * scale.length];
return 440 * 2 ** ((midi - 69) / 12);
}

Flip the weekday on purpose: Sunday at the top of the grid gets the highest step, Saturday at the bottom gets the lowest. Scrolling your eyes down a column reads top-to-bottom, high-to-low, the same direction you’d already scan a calendar. chord comes from the week’s own month (chordOf below), so the key centre drifts every few months instead of every single day:

Terminal window
const chordOf = (week: Week) =>
Number((week.contributionDays[3] ?? week.contributionDays[0]).date.slice(5, 7)) % 4;

Press the buttons below and you’ll hear the raw result: an oscillator, started and stopped with nothing in between.

Step 1 · a raw oscillator per weekday

It works, but it sounds like a doorbell with a stuck finger — the gain jumps straight to full volume and straight back to zero, which is an audible click at both ends. That’s the next problem to fix.

Shaping a voice so it doesn’t click

A real sound has a shape: it fades in, settles, and fades out. That shape is an envelope, and Web Audio gives you setTargetAtTime for it — an exponential approach to a target instead of a hard jump, so there’s no edge for your speakers to click on:

Terminal window
const envelope = ctx.createGain();
envelope.gain.setValueAtTime(0, start);
envelope.gain.linearRampToValueAtTime(peak, start + Math.max(0.003, voice.attack));
envelope.gain.setTargetAtTime(peak * voice.sustain, start + voice.attack, voice.decay);
envelope.gain.setTargetAtTime(0, releaseStart, voice.release);

The filter gets the same treatment, sweeping from a brighter cutoff down to a resting one as the note decays — which is most of why a plucked note sounds plucked:

Terminal window
const restingCutoff = Math.min(12000, voice.cutoff * (0.6 + 0.6 * velocity));
filter.frequency.setValueAtTime(Math.min(12000, restingCutoff * voice.sweep), start);
filter.frequency.setTargetAtTime(restingCutoff, start + voice.attack, voice.decay * 0.8);

velocity comes straight from the contribution level (1 to 4), and it doesn’t just control volume — it brightens the filter too. A quiet day doesn’t just play softer, it plays a little darker, closer to how a real instrument responds to a gentle touch.

Step 2 · the same weekdays, shaped

That demo is already playing two oscillators per note, not one: a quiet copy pitched a twelfth up (ratio2: 3) for shimmer, each one detuned a few cents in opposite directions (-voice.detuneCents and +voice.detuneCents) so the pair beats very slightly against itself instead of landing on one dead-flat pitch. That’s the whole trick behind “chimes” versus “8-bit” versus “pad” — same envelope code, different numbers.

Picking a preset and a mood

Four presets, three moods, each just a plain object:

Terminal window
const VOICES: Record<PresetId, Voice> = {
chimes: { wave: 'sine', wave2: 'sine', ratio2: 3, gain2: 0.16, detuneCents: 3,
attack: 0.003, decay: 0.35, sustain: 0, cutoff: 5000, sweep: 1.5, gain: 1, space: 0.5 },
bit: { wave: 'square', wave2: 'square', ratio2: 1, gain2: 0, detuneCents: 0,
attack: 0.002, decay: 0.05, sustain: 0.3, cutoff: 2600, sweep: 1.5, gain: 0.5, space: 0.08 },
pad: { wave: 'sawtooth', wave2: 'sawtooth', ratio2: 1, gain2: 1, detuneCents: 8,
attack: 0.16, decay: 0.25, sustain: 0.7, cutoff: 1200, sweep: 1.6, gain: 0.5, space: 0.75 },
cosmic: { wave: 'triangle', wave2: 'sawtooth', ratio2: 1, gain2: 0.45, detuneCents: 6,
attack: 0.004, decay: 0.28, sustain: 0, cutoff: 700, sweep: 6, gain: 0.65, space: 0.8 },
};

Switching preset also resets the room (settings.space = VOICES[preset].space), so jumping from a dry 8-bit pluck to a wide cosmic pad doesn’t leave you stuck in a reverb amount tuned for the wrong voice:

Terminal window
if (input.name === 'preset') {
settings.space = VOICES[settings.preset].space;
engine.applySpace();
}

Step 3 · preset + mood, one chord

Giving it a room to live in

A convolution reverb needs an impulse response — a recording of how some space (real or imagined) reacts to a single click. There’s no room to record, so the real code fakes one: filtered noise that darkens and fades over a couple of seconds.

Terminal window
function createReverbImpulse(ctx: BaseAudioContext, seconds = 2.2) {
const length = Math.floor(ctx.sampleRate * seconds);
const impulse = ctx.createBuffer(2, length, ctx.sampleRate);
for (let channel = 0; channel < 2; channel++) {
const data = impulse.getChannelData(channel);
let smoothed = 0;
for (let i = 0; i < length; i++) {
const progress = i / length;
smoothed += (Math.random() * 2 - 1 - smoothed) * (0.9 - 0.75 * progress);
data[i] = smoothed * (1 - progress) ** 3;
}
}
return impulse;
}

That one-pole smoothing is doing the real work: white noise on its own sounds like static, but nudging each sample toward the last one — a little less each step — makes the tail close like a real room absorbing high frequencies as the sound dies away. A short delay line with feedback sits alongside it for echo, feeding back into itself and into the reverb:

Terminal window
const echo = ctx.createDelay(1);
echo.delayTime.value = 0.3;
const echoFeedback = ctx.createGain();
echoFeedback.gain.value = 0.38;
echo.connect(echoDamping);
echoDamping.connect(echoFeedback);
echoFeedback.connect(echo);

Both sends are just gain nodes between the dry signal and these two effects, so “space” is one number that scales both at once:

Step 4 · dry → wet

Turning hover into an instrument

The grid already has every cell’s weekday, level, and chord sitting in data-* attributes from the drawing step. Playing it is one pointerover listener, plus a throttle so dragging across the same cell twice doesn’t retrigger it:

Terminal window
const lastHoverAt = new WeakMap<Element, number>();
function triggerCell(cell: SVGElement, timeStamp: number) {
const level = Number(cell.dataset.level);
const sinceLast = timeStamp - (lastHoverAt.get(cell) ?? -Infinity);
if (!settings.sound || level === 0 || sinceLast < MIN_RETRIGGER_MS) return;
lastHoverAt.set(cell, timeStamp);
engine.play(noteFrequency(settings.mood, Number(cell.dataset.day), Number(cell.dataset.chord)), level);
}

Non-obvious bit: on a touchscreen, the element under your finger when you press down captures the pointer, so pointerover never fires for the cells your finger slides across afterward. One line fixes it for mouse and touch alike: event.target.releasePointerCapture?.(event.pointerId) on pointerdown, right before reading the first cell.

Step 5 · hover or drag to play

Playing the whole year

Press play, and the grid turns into a sequencer: one column at a time, left to right, every lit cell in that column strummed together.

Terminal window
function playColumn(notes: ColumnNote[]) {
const lit = notes.filter((note) => note.level > 0).sort((a, b) => b.weekday - a.weekday);
const loudness = 1 / Math.sqrt(lit.length);
const now = engine.time;
lit.forEach((note, index) => {
const frequency = noteFrequency(settings.mood, note.weekday, note.chord);
engine.play(frequency, note.level, loudness, now + index * STRUM_SECONDS);
});
}

1 / Math.sqrt(lit.length) is a standard trick for mixing several roughly-independent voices: without it, a five-note column would hit five times harder than a one-note column, and busy weeks would just distort. STRUM_SECONDS offsets each note by 22 milliseconds so a loaded column arrives as a quick strum instead of one flat chord. The playhead is a <rect> nudged with translateX, and the whole thing reschedules itself every 30000 / tempo milliseconds — two columns per beat, so it still feels like it’s moving at the tempo you picked instead of crawling one week at a time.

Step 6 · press play

Keeping it polite

A few small things keep the instrument from being annoying rather than fun:

  • Voice stealing. At most 12 notes ring at once (MAX_VOICES); past that, the oldest one fades out early instead of a 13th note piling on top and distorting the mix.
  • Fade, don’t cut. Stopping a voice early still runs it through setTargetAtTime(0, now, 0.01) first — a fast fade, not an instant stop, so stealing a voice never clicks either.
  • Tab away, sound off. A visibilitychange listener stops playback the moment the tab isn’t visible, so a forgotten background tab doesn’t keep playing a year of chimes at whoever’s in the room.
  • Settings stick. Preset, mood, tempo, and the sound toggle are saved to localStorage under one key, read back with fallbacks for anything missing or out of range, so a corrupted or half-written value can’t crash the page on the next visit.

Why this matters more than it looks: every one of these is cheap to add and easy to skip under deadline. None of them show up in a screenshot. All four are the difference between “neat toy” and “thing I’d actually leave on while I work.”

The result

Here’s all of it together: the grid from the real heatmap, hover-to-play, the sequencer, presets, moods, and the reverb send — wired to the same small engine as every demo above.

Full demo · hover, drag, or press play See it on the homepage ↗

Same idea as the heatmap itself: none of this needed a framework or an audio library. A dozen Web Audio nodes, a scale, and a few data-* attributes already sitting on the grid were enough to turn a wall of green squares into something you’d actually want to poke at.