DEV Community

SEN LLC
SEN LLC

Posted on

Building a Chord Progression Generator in the Browser — Music Theory in JS, Sound via Web Audio API

Pop songs, jazz turnarounds, Pachelbel's canon. Chord progressions you've heard a thousand times all reduce to scale degrees stacked on a diatonic scale. This tool generates progressions in 12 keys × 2 scales × 7 genre presets and plays them through the Web Audio API. ~300 lines of vanilla JS, zero dependencies.

🌐 Demo: https://sen.ltd/portfolio/chord-progression-gen/
📦 GitHub: https://github.com/sen-ltd/chord-progression-gen

Screenshot

The minimum music theory you need

If "music theory" sounds heavy, the slice you actually need to write code is surprisingly small.

1. A scale is just 7 notes

The C major scale is C, D, E, F, G, A, B. In semitones from the root: [0, 2, 4, 5, 7, 9, 11]. That's it.

const MAJOR_SCALE = [0, 2, 4, 5, 7, 9, 11];
const NATURAL_MINOR = [0, 2, 3, 5, 7, 8, 10];
Enter fullscreen mode Exit fullscreen mode

Natural minor (C, D, E♭, F, G, A♭, B♭) is [0, 2, 3, 5, 7, 8, 10].

2. A chord stacks the 1st, 3rd, and 5th degrees (diatonic triad)

Take notes 1, 3, 5 of the scale: C, E, G → C major triad. Start from note 2 instead: D, F, A → D minor. The starting position is the "scale degree" and we label it with a roman numeral:

Degree C major Quality
I C Major
ii Dm minor
iii Em minor
IV F Major
V G Major
vi Am minor
vii° diminished

Uppercase = major, lowercase = minor, ° = diminished. Every chord progression in this app is a sequence of these seven.

3. A progression is just an ordered list of degrees

  • Pop: I → V → vi → IV (in C: C → G → Am → F — the "Axis of Awesome" progression)
  • Pachelbel's Canon: I → V → vi → iii → IV → I → IV → V
  • Jazz turnaround: ii → V → I → vi

Building the chord

The triad-building function:

export function chordTones(tonic, scaleName, degree, seventh = false) {
  const scale = SCALES[scaleName];
  const positions = seventh ? [0, 2, 4, 6] : [0, 2, 4];
  return positions.map((p) => {
    const idx = degree + p;
    const octaveShift = Math.floor(idx / 7) * 12;
    return tonic + octaveShift + scale.intervals[idx % 7];
  });
}
Enter fullscreen mode Exit fullscreen mode

tonic is the MIDI number of the root (C4 = 60). When degree + p walks past the seventh scale note we need to wrap to the next octave, hence Math.floor(idx / 7) * 12. Building vi (Am) in C major: chordTones(60, "major", 5) returns [69, 72, 76] — A4, C5, E5.

For seventh chords, change positions to [0, 2, 4, 6]. The chord quality (maj7 / 7 / m7 / m7♭5) we derive from the interval pattern afterwards.

Naming chords without a lookup table

We could hardcode "Am" → "minor on A", "Bm7♭5" → "half-diminished on B" and so on. But that table has 12 keys × 7 degrees × 4 qualities × {triad, seventh} entries, and it doesn't extend when you add modes (Dorian, Phrygian, harmonic minor).

Better: derive the name from the interval pattern of the constructed chord:

export function chordName(tonic, scaleName, degree, seventh = false) {
  const tones = chordTones(tonic, scaleName, degree, seventh);
  const rootPc = ((tones[0] % 12) + 12) % 12;
  const third = tones[1] - tones[0];
  const fifth = tones[2] - tones[0];
  let quality = "";
  if (third === 4 && fifth === 7) quality = "";      // Major
  else if (third === 3 && fifth === 7) quality = "m"; // minor
  else if (third === 3 && fifth === 6) quality = "°"; // dim
  else if (third === 4 && fifth === 8) quality = "+"; // aug
  // ... seventh logic continues
  return NOTE_NAMES[rootPc] + quality;
}
Enter fullscreen mode Exit fullscreen mode

Major third (4 semis) + perfect fifth (7 semis) → Major. Minor third (3) + perfect fifth (7) → minor. Minor third (3) + diminished fifth (6) → diminished. Names fall out of the intervals, so adding Dorian or harmonic minor later doesn't require touching the naming logic at all.

Playing it through Web Audio

The audio engine is the kind of thing that looks complicated and turns out to be 30 lines.

import { midiToHz } from "./theory.js";

class ChordPlayer {
  constructor() {
    this.ctx = null;
  }

  ensureContext() {
    if (this.ctx) return;
    this.ctx = new AudioContext();
    this.master = this.ctx.createGain();
    this.master.gain.value = 0.18;
    const filter = this.ctx.createBiquadFilter();
    filter.type = "lowpass";
    filter.frequency.value = 2400;
    this.master.connect(filter);
    filter.connect(this.ctx.destination);
  }

  scheduleChord(tones, startTime, duration) {
    for (const midi of tones) {
      const osc = this.ctx.createOscillator();
      osc.type = "triangle";
      osc.frequency.value = midiToHz(midi);
      const gain = this.ctx.createGain();
      gain.gain.setValueAtTime(0, startTime);
      gain.gain.linearRampToValueAtTime(1 / tones.length, startTime + 0.02);
      gain.gain.linearRampToValueAtTime(0, startTime + duration);
      osc.connect(gain);
      gain.connect(this.master);
      osc.start(startTime);
      osc.stop(startTime + duration + 0.05);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Four small choices matter:

1. Use triangle oscillators

sawtooth is too harsh, sine is too thin, square sounds like a Game Boy. triangle lands in the middle and reads as "acoustic-ish" without effort.

2. ADSR can be trivial

Real pianos and strings have complex envelopes. For chord progressions you don't need them. A 20 ms attack and a short linear release is enough to avoid clicks and sound natural:

volume
 1.0 │   /‾‾‾‾‾‾‾‾‾‾‾\
     │  /              \
 0.0 │_/                \____
     ├──┼──────────────┼─┼──→ time
        20ms          release start
Enter fullscreen mode Exit fullscreen mode

3. Divide gain by chord size

Stacking 3 or 4 oscillators at full volume sums to clipping. Multiply each oscillator's gain by 1 / tones.length and the master stays well-behaved no matter how many voices you add.

4. Resume the context on user gesture

Chrome's autoplay policy refuses to let AudioContext make sound until the user has interacted with the page. If you call osc.start() before ctx.resume(), the first chord goes silently into the void:

async resume() {
  this.ensureContext();
  if (this.ctx.state === "suspended") await this.ctx.resume();
}
Enter fullscreen mode Exit fullscreen mode

Skip this and you get the classic "works locally, silent in production" trap.

Architecture

theory.js   ← Music theory (pure, zero DOM/Audio dependencies)
audio.js    ← Web Audio scheduler (consumes MIDI numbers from theory.js)
app.js      ← UI glue (DOM events → theory → audio)
Enter fullscreen mode Exit fullscreen mode

Nothing in theory.js knows about the DOM or about AudioContext. That means Node's built-in test runner can verify all 25 cases of the music logic without a browser:

import { test } from "node:test";
import assert from "node:assert/strict";
import { buildProgression, tonicMidi, PRESETS } from "../theory.js";

test("Pop in C major yields C G Am F", () => {
  const out = buildProgression({
    tonic: tonicMidi("C", 4),
    scaleName: "major",
    degrees: PRESETS.pop.degrees,
  });
  assert.deepEqual(out.map((c) => c.name), ["C", "G", "Am", "F"]);
});
Enter fullscreen mode Exit fullscreen mode

audio.js consumes the output of theory.js (MIDI number arrays) and has no opinion about scales or roman numerals. The dependency arrow only points one direction. When you eventually add Dorian, harmonic minor, secondary dominants, or jazz substitutions, the audio layer doesn't change at all — theory.js keeps emitting MIDI, the rest keeps playing it.

app.js is a tiny coordinator: UI event → mutate state → rebuild progression via theory → optionally play through audio. No React, no Vue, no signals. The whole state is a plain object.

Try it

Takeaways

  • Chord progressions reduce to 7 notes × scale degrees × roman numerals — small enough to fit in your head
  • Deriving chord names from interval relationships instead of a lookup table makes the music-theory module trivially extensible
  • The Web Audio API plays chord progressions cleanly with triangle waves + minimal ADSR + autoplay-policy handling — that's the whole synthesis layer
  • Keeping music theory pure (no DOM, no audio) means node --test can verify all the harmony rules without spinning up a browser

This is OSS portfolio #240 from SEN LLC (Tokyo). We ship small, sharp tools continuously: https://sen.ltd/portfolio/

Top comments (0)