Learn Creative Coding (#120) - Generative Melody and Harmony

avatar

Learn Creative Coding (#120) - Generative Melody and Harmony

cc-banner

Two episodes ago we made a computer sing, last episode we cracked open a synth and learned to build the sound from raw sines and filters and noise. And both times I kept typing the actual tune by hand, ["C4", "E4", "G4"], like some kind of caveman, while promising you we'd fix that soon. Well, today's the day we fix it. Today we stop choosing the notes and teach the code to choose them for us - and, crucially, to choose them so it actually sounds musical and not like a fax machine having a breakdown. Allez, this is the one I've been most excited to write in the whole audio stretch :-).

Here's the honest truth that scares people off generative music: if you just fire random notes, it sounds terrible. Genuinly awful. You cannot Math.random() your way to a melody the way you can sometimes Math.random() your way to a pretty visual, because your ear is brutally unforgiving about pitch in a way your eye isn't about colour. A random splatter of paint can look nice. A random splatter of notes never sounds nice. So the whole game today is constraint - random within a frame, freedom on a leash. And once you see the handful of tricks that do the constraining, you'll never hear music the same way again. Let me show you.

Notes are just numbers (and that changes everything)

Remember from episode 118 that a pitch has three names - the human one ("C4"), the MIDI number (60), and the raw frequency (261.63 Hz). For generating melodies, MIDI numbers are the only sensible choice, because they turn music into arithmetic. Every semitone up is +1. An octave is +12. Want to transpose a whole tune up a fifth? Add 7 to every number. That's it.

// MIDI numbers turn pitch into maths. every +1 is one semitone (half-step).
const midi = {
  C4: 60,   // middle C, our comfy home base
  E4: 64,   // 60 + 4 semitones
  G4: 67,   // 60 + 7 semitones
  C5: 72,   // 60 + 12 = one octave up. octaves are just +12.
};
// tone.js speaks MIDI via a tiny helper, so we can generate numbers and play them:
// Tone.Frequency(60, "midi").toNote()  ->  "C4"

So a "melody" from the computer's point of view is just an array of integers we generate however we like, then hand to a synth. The whole challenge is: which integers, and in what order, so it sounds like music instead of noise. Everything below is a different answer to that one question.

Scales: the fence that keeps you in tune

The first and biggest trick is the scale. Out of the 12 chromatic notes in an octave, only certain subsets sound good together as a group. That subset is a scale. Pick a scale, only ever play notes from it, and a huge amount of "wrongness" becomes impossible. It's the single highest-leverage constraint in all of music.

A scale is really just a pattern of gaps - how many semitones between each step. The major scale (bright, happy) goes 2-2-1-2-2-2-1. The natural minor (darker, sadder) goes 2-1-2-2-1-2-2. Same twelve notes underneath, different fence drawn around them.

// a scale = a pattern of semitone GAPS from the root note.
const scalePatterns = {
  major:      [2, 2, 1, 2, 2, 2, 1],   // bright, happy (do-re-mi)
  minor:      [2, 1, 2, 2, 1, 2, 2],   // dark, sad
  pentatonic: [2, 2, 3, 2, 3],         // 5 notes, the "no wrong notes" scale
  blues:      [3, 2, 1, 1, 3, 2],      // gritty, expressive
};

// turn a root MIDI note + a pattern into the actual playable notes.
function buildScale(rootMidi, pattern, octaves = 2) {
  const notes = [];
  let n = rootMidi;
  for (let o = 0; o < octaves; o++) {
    for (const gap of pattern) {
      notes.push(n);   // add the current note
      n += gap;        // step up by the gap to the next
    }
  }
  notes.push(n);       // cap it off with the top octave root
  return notes;
}

// C minor pentatonic across two octaves - a fistful of notes that all agree.
const scale = buildScale(60, scalePatterns.pentatonic, 2);
// -> [60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84]

The pentatonic deserves a special shout. It's five notes instead of seven, and someone very kindly removed the two notes most likely to clash. The result is genuinly magic: you can play those five notes in any order, at any time, and it will sound fine. It's the reason it's the beginner's cheat code, and it's exactly where we'll start generating.

Constrained randomness: random, but always in key

Now the payoff. Instead of random pitches, we pick random indices into the scale array. The randomness is total, but every note that comes out is guaranteed to be in key. This one idea is the backbone of maybe half of all generative music.

// pick random NOTES FROM THE SCALE, never random pitches. always in key.
function randomFromScale(scale) {
  const i = Math.floor(Math.random() * scale.length);
  return scale[i];
}

// generate an 8-note phrase - completely random, and completely in tune.
const phrase = Array.from({ length: 8 }, () => randomFromScale(scale));
// e.g. [67, 72, 60, 79, 69, 62, 84, 67] - refresh and it's different every time.

Play that through your synth from last episode and, on a pentatonic scale, it will always sound at least okay. That's the difference between random-pitch (garbage) and random-within-a-scale (music-ish). But "okay" isn't "good", and if you listen a while you'll notice the problem: it leaps around like a startled cat. Real melodies mostly step, moving to nearby notes, with the occasional dramatic leap. Which brings us to the next dial.

Weighted randomness: probability IS the composition

Pure random treats every note equally likely. But music has tendencies - it prefers small steps, it returns home to the root, it leaps only now and then for drama. We encode all of that with weighted randomness: some choices simply more likely than others. And here's the mindset shift I want you to really sit with - the weights are the composition. When you tune the probabilities, you're composing.

// weighted pick: each item has a weight, higher weight = more likely.
function weightedPick(items, weights) {
  const total = weights.reduce((a, b) => a + b, 0);
  let r = Math.random() * total;
  for (let i = 0; i < items.length; i++) {
    r -= weights[i];
    if (r <= 0) return items[i];
  }
  return items[items.length - 1];   // float-rounding safety net
}

Now let's use it to prefer stepwise motion. Instead of jumping anywhere in the scale, we mostly move one or two scale-steps from where we are, and only rarely leap far. That single change turns the startled-cat splatter into something that sounds like it has a line, a direction.

// a melody that mostly STEPS and rarely LEAPS - the shape of real tunes.
function stepwiseMelody(scale, length = 16) {
  let idx = Math.floor(scale.length / 2);   // start somewhere in the middle
  const out = [scale[idx]];
  // possible moves in scale-steps, and how much we prefer each.
  const moves   = [-3, -2, -1, 0, 1, 2, 3];
  const weights = [ 1,  3,  6, 2, 6, 3, 1];   // small steps hugely favoured
  for (let i = 1; i < length; i++) {
    idx += weightedPick(moves, weights);
    idx = Math.max(0, Math.min(scale.length - 1, idx));   // stay inside the scale
    out.push(scale[idx]);
  }
  return out;
}

Feel what happened there? Same scale, same randomness engine, but by making -1 and +1 six times more likely than a big leap, the melody suddenly walks instead of teleporting. Nudge those weights and the whole character changes - more leaps for a jumpy, playful line, more zeros for a hesitant, hovering one. You're not writing notes anymore, you're writing the odds, and the odds paint the mood. Makes sense, right?

Intervals: why some jumps feel nice and others feel wrong

To go further we need one bit of theory: the interval, the distance between two notes. It matters because some distances sound stable (consonant) and some sound tense (dissonant), and that tension is a tool, not a mistake.

// intervals = distance in semitones. some sound stable, some tense.
const intervals = {
  unison:      0,   // same note. total rest.
  minorSecond: 1,   // the "jaws" note. maximum tension, feels wrong on purpose.
  majorThird:  4,   // sweet, happy, the top of a major chord.
  fourth:      5,   // stable, open, hymn-like.
  fifth:       7,   // rock solid. the most consonant interval after the octave.
  tritone:     6,   // the "devil's interval". restless, unresolved, evil-sounding.
  octave:     12,   // same note, higher. perfect agreement.
};

You don't need to memorise these, but knowing that a fifth (7) is rock-stable and a minor second (1) or tritone (6) is deliberately edgy gives you a knob for tension. Want unease in a section? Allow more dissonant intervals. Want release? Resolve down onto a stable one. We'll use exactly that in a minute for the emotional arc.

Chords and harmony: notes stacked, not strung

So far it's all been one note after another - melody, the horizontal line. Harmony is the vertical stack: notes played together. The basic unit is the chord, and the friendliest chord is the triad - three notes, built by taking a scale note and adding the ones two and four scale-steps above it.

// a triad: take a scale note, stack the notes 2 and 4 scale-steps above it.
function triad(scale, rootIndex) {
  return [
    scale[rootIndex],
    scale[rootIndex + 2],
    scale[rootIndex + 4],
  ];
}

// play a chord = hand the array to a PolySynth (from episode 118).
const poly = new Tone.PolySynth(Tone.Synth).toDestination();
poly.triggerAttackRelease(
  triad(scale, 0).map((m) => Tone.Frequency(m, "midi").toNote()),
  "2n"
);

Stack three notes that agree and you get a mood in one strike - major triads feel bright, minor ones feel wistful. That's harmony in its smallest form. But chords rarely sit alone; they move in sequences, and those sequences are startlingly predictable.

Chord progressions: the four chords behind every pop song

Here's a thing that feels like a cheat once you know it: an enormous amount of popular music runs on a tiny handful of chord progressions. We number the chords by their position in the scale with Roman numerals - I, IV, V, and so on. And a few sequences show up absolutely everywhere.

// chord progressions as SCALE DEGREES (0-indexed: I=0, ii=1, IV=3, V=4, vi=5).
const progressions = {
  popClassic:  [0, 3, 4, 0],   // I-IV-V-I  - the oldest trick in the book
  fourChords:  [0, 4, 5, 3],   // I-V-vi-IV - literally "every pop song ever"
  minorPop:    [5, 3, 0, 4],   // vi-IV-I-V - the melancholy cousin
  jazzTwoFive: [1, 4, 0, 0],   // ii-V-I    - the backbone of jazz
};

// turn a progression into actual chords from our scale.
function chordsFromProgression(scale, degrees) {
  return degrees.map((deg) => triad(scale, deg));
}

Point a generator at fourChords and it will spit out harmony that sounds instantly, almost suspiciously familiar - because your ear has heard that exact journey ten thousand times. Now the real trick: generate a chord progression as the backbone, then generate a melody that leans on the chord tones. Melody and harmony agreeing, both from code.

// generate a melody that "knows" the current chord - lands on chord tones,
// passes through scale tones. this is what makes it sound INTENTIONAL.
function melodyOverChords(scale, progression, notesPerChord = 4) {
  const chords = chordsFromProgression(scale, progression);
  const melody = [];
  for (const chord of chords) {
    for (let i = 0; i < notesPerChord; i++) {
      // 70% land on a chord tone (stable), 30% a passing scale tone (colour).
      if (Math.random() < 0.7) {
        melody.push(chord[Math.floor(Math.random() * chord.length)]);
      } else {
        melody.push(randomFromScale(scale));
      }
    }
  }
  return melody;
}

That 70/30 split is doing something subtle and lovely: the melody keeps touching base with the chord underneath it, so even the random passing notes sound purposeful rather than accidental. Land mostly on chord tones and you sound resolved and singable; wander more and you sound jazzy and searching. One number, a whole aesthetic.

Markov chains: melodies that have a "style"

Want your generator to sound like a particular kind of music rather than generic constrained-random? A Markov chain is the tool, and it's far less scary than it sounds. The idea: look at an existing melody and learn, for each note, what note tends to follow it. Then generate new melodies by walking those probabilities. The output has the statistical "feel" of the source without copying it note-for-note.

// build a transition table: for each note, which notes followed it (and how often).
function buildMarkov(sourceMelody) {
  const table = {};
  for (let i = 0; i < sourceMelody.length - 1; i++) {
    const cur = sourceMelody[i];
    const next = sourceMelody[i + 1];
    if (!table[cur]) table[cur] = [];
    table[cur].push(next);   // just collect every note that came after `cur`
  }
  return table;
}

// walk the table: from a note, pick randomly among the notes that historically followed.
function markovMelody(table, start, length = 16) {
  const out = [start];
  let cur = start;
  for (let i = 1; i < length; i++) {
    const options = table[cur];
    if (!options || options.length === 0) break;   // dead end, stop
    cur = options[Math.floor(Math.random() * options.length)];
    out.push(cur);
  }
  return out;
}

Feed it a folk tune and it generates folk-flavoured lines. Feed it a bassline and it babbles in basslines. It's genuinly one of my favourite tricks because it's so cheap for how clever it feels - the "learning" is a couple of loops, but the result carries the fingerprint of whatever you trained it on. This is the exact same probability-walks-a-graph idea we brushed against with Perlin noise in episode 12, just moved from space into pitch.

Contour and call-and-response: shape and conversation

Two last ideas that push generative melody from "fine" to "actually interesting", because they add structure on top of the note-picking.

The first is contour - the overall shape of a phrase, ignoring the specific notes. Ascending feels like a question or a lift, descending feels like an answer or a sigh, an arch rises then falls. Generate the shape first, then fill in notes that follow it. It separates the gesture from the content, which is a genuinly powerful way to think.

// generate a CONTOUR (a shape) first, then fill notes that follow it.
function contourMelody(scale, contour) {
  // contour is a list of directions: 1 up, -1 down, 0 stay.
  let idx = Math.floor(scale.length / 2);
  return contour.map((dir) => {
    idx = Math.max(0, Math.min(scale.length - 1, idx + dir));
    return scale[idx];
  });
}
const archUp   = [1, 1, 1, 1];        // a rising question
const archDown = [-1, -1, -1, -1];    // a falling answer

The second is call and response, one of the oldest compositional tricks there is. Generate a phrase (the "call"), then generate a "response" that echoes it but changes the ending - shares the opening, resolves differently. It's a musical conversation, and your ear loves it because it hears both the repetition and the little surprise.

// call & response: the response copies the call's opening, varies its ending.
function callAndResponse(scale, callLength = 4) {
  const call = stepwiseMelody(scale, callLength);
  const response = call.slice(0, callLength - 1);      // echo all but the last note
  response.push(randomFromScale(scale));               // ...then answer differently
  return [...call, ...response];
}

That tiny bit of repetition with variation is, honestly, the secret ingredient in nearly all music that sticks in your head. Pure randomness never repeats, so it never gives your ear something to hold onto. The moment a phrase returns - slightly changed - it stops being noise and becomes a tune. Remember that: generative does not mean random. The best generative music is structure with a little chaos sprinkled on top, not chaos with a little structure.

Tension and release: the emotional arc, in code

Let's tie the theory into feeling. Music moves you by building tension and then granting release. You build tension with rising pitch, denser notes, more dissonant intervals. You release by falling to a stable, consonant note - usually the root. We can literally schedule that arc.

// build tension (climb + dissonance) then release (fall home to the root).
function tensionRelease(scale) {
  const climb = [0, 2, 4, 6, 8];              // rising scale indices - lifting up
  const peak  = [scale.length - 1];           // highest note - maximum tension
  const fall  = [6, 4, 2, 0];                 // descending - letting the air out
  const home  = [0];                          // the root - total rest, we're home
  return [...climb, ...peak, ...fall, ...home].map((i) => scale[i]);
}

Play that and you'll feel the little journey - a lift, a held breath at the top, a sigh back down to home. That arc, scaled up and repeated with variation, is the skeleton of pretty much every piece of music ever written. And you just expressed it as an array of indices. That's the thing I never get over with this stuff: emotion, rendered as arithmetic, and it still lands in your chest.

Your exercise: build a generative melody machine

Right, let's put the whole toolbox into one satisfying little instrument. The brief: every time you refresh (or hit a button), it generates a fresh melody that's different every time but always coherent - in key, mostly stepwise, leaning on a chord progression underneath, with a second voice for harmony. This is the real deal, a machine that composes.

// THE GENERATIVE MELODY MACHINE. fresh, coherent tune on every run.
const root = 60;                                            // C4
const scale = buildScale(root, scalePatterns.pentatonic, 2);
const progression = progressions.fourChords;                // I-V-vi-IV

const lead = new Tone.Synth({
  oscillator: { type: "triangle" },
  envelope: { attack: 0.01, decay: 0.2, sustain: 0.2, release: 0.4 },
}).toDestination();

const harmony = new Tone.PolySynth(Tone.Synth).toDestination();
harmony.volume.value = -10;   // keep the chords softer, sitting under the lead

Now build the actual material - a melody that follows the chords, and the block chords themselves to sit underneath as a bed.

// generate the material: melody over the progression + the backing chords.
const melody = melodyOverChords(scale, progression, 4);     // 16 notes
const chords = chordsFromProgression(scale, progression)    // 4 triads
  .map((c) => c.map((m) => Tone.Frequency(m, "midi").toNote()));

And schedule it with the Transport from episode 118 - the lead steps through the melody one note at a time, while the harmony changes chord once per bar underneath it.

// play it: lead walks the melody, chords change once per bar beneath it.
let step = 0;
const leadLoop = new Tone.Loop((time) => {
  const m = melody[step % melody.length];
  lead.triggerAttackRelease(Tone.Frequency(m, "midi").toNote(), "8n", time);
  step++;
}, "8n");

let bar = 0;
const chordLoop = new Tone.Loop((time) => {
  harmony.triggerAttackRelease(chords[bar % chords.length], "1n", time);
  bar++;
}, "1n");   // one chord per whole-note bar

leadLoop.start(0);
chordLoop.start(0);

Wire it behind a "click to start" button (never forget Tone.start(), we learned that the hard way), and you've got a machine that writes a new little song every time. Same scale, same progression, endlessly different melodies - all of them in tune, all of them leaning on the harmony, none of them random noise. That gap between "random beeps" and "this actually sounds like music" is entirely the constraints we stacked up today.

// the unlock button - build the loops up top, only START from a click.
document.querySelector("#startButton").addEventListener("click", async () => {
  await Tone.start();          // wake the audio engine
  Tone.Transport.bpm.value = 100;
  Tone.Transport.start();      // and away it composes
});

Push it when you've got an evening: swap the pentatonic for minor and feel the mood drop; add a tensionRelease phrase every fourth bar for a chorus that lifts; train a Markov chain on a melody you love and let the machine babble in its accent; or feed each note's pitch into a p5 sketch so the height of a shape tracks the tune. It's your composer now, and it never runs out of ideas :-).

't Komt erop neer...

  • A melody, to the computer, is just an array of MIDI numbers - and that's the whole trick, because numbers let you do arithmetic on pitch (+1 = a semitone, +12 = an octave, +7 = transpose up a fifth)
  • You cannot random your way to music the way you can sometimes random your way to a visual - your ear is merciless about pitch. The entire game is constrained randomness: freedom on a leash
  • The biggest constraint is the scale - a subset of the 12 notes that agree. Pick random indices into the scale and every note is guaranteed in key. The pentatonic (5 notes) is the "no wrong notes" cheat code and the best place to start
  • Weighted randomness is where composition really lives: favour small stepwise moves over big leaps and a splatter becomes a line. The weights ARE the composition - tune the odds, paint the mood
  • Intervals (distance in semitones) carry feeling: a fifth (7) is rock-stable, a minor second (1) or tritone (6) is deliberately tense. Tension is a tool, not a bug
  • Harmony is notes stacked vertically. A triad is a scale note plus the ones 2 and 4 steps above. Chord progressions (I-IV-V-I, I-V-vi-IV, ii-V-I) are the predictable backbones behind almost all popular music - generate the progression, then write a melody that lands on the chord tones (70/30 was our split) and it sounds intentional
  • Markov chains learn "what note tends to follow what" from a source tune, then generate new melodies with that same statistical accent - clever-feeling for how cheap it is
  • Contour separates a phrase's shape from its notes; call and response gives you repetition-with-variation, which is the real secret to a tune that sticks. Generative does NOT mean random - it means structure with a little chaos on top
  • Tension and release - climb and dissonance, then a fall home to the root - is the emotional arc of music, and you can literally schedule it as an array of indices

So that's melody and harmony, demystified into a handful of constraints you can stack. You can now generate a tune that's fresh every single time and always sounds like music, because you've fenced the randomness in with scales, weights, chords and shape. That's a genuinly powerful place to be standing - a machine in your browser that composes, and never plays the same thing twice.

But notice what we've been quietly ignoring this whole episode, again: when the notes fall. Every note in our machine is a tidy eighth-note, marching in a perfectly even line like little soldiers. Real music doesn't march - it grooves, it swings, it leaves gaps and stacks up bursts, and that living, breathing sense of timing is a whole craft of its own. We've solved which notes to play; next we solve when to play them, and it's every bit as much fun as it sounds. Build your melody machine first - genuinly sit with it until it hands you a phrase you actually love - and then come back, because next time we give the tune a heartbeat :-).

Sallukes! Thanks for reading.

X

@femdev



0
0
0.000
0 comments