← How a game is made: Daily Challenge's Snake · Lesson 12/16
Music from a recipe
A song in eleven lines
Each challenge's music comes from a recipe: a text file in public/content/music/ with key: value lines. A recipe doesn't contain notes: it holds the rules (tempo, root, scale, chord progression, structure, waves) and the day's seed writes the melody. So there's a different song every day without composing any.
There's another kind, the fixed tune (tune:), with public-domain pieces written note by note; there the seed only picks the bass and drums.
The program reads the chip_01.txt recipe with the game's parseMusicSpec, summarized.
$ node leccion.js
Press RUN (or Ctrl+Enter).
What to look at
- The top comment (
#) is skipped; the rest becomes an object of strings. i VI III VIIare scale degrees in Roman numerals: the root chord, then the sixth, third and seventh of the minor scale.AABA: section A plays twice, then a B, then A again, as in so many songs.
The whole lesson on one page
A song in eleven lines
Each challenge's music comes from a recipe: a text file in public/content/music/ with key: value lines. A recipe doesn't contain notes: it holds the rules (tempo, root, scale, chord progression, structure, waves) and the day's seed writes the melody. So there's a different song every day without composing any.
There's another kind, the fixed tune (tune:), with public-domain pieces written note by note; there the seed only picks the bass and drums.
The program reads the chip_01.txt recipe with the game's parseMusicSpec, summarized.
const receta = [
"# Receta 01: menor pentatónica, tipo arcade oscuro",
"tempo: 160",
"root: C4",
"scale: minor_pentatonic",
"harmony: minor",
"progression: i VI III VII",
"bars: 8",
"structure: AABA",
"lead: square",
"bass: triangle",
"drums: on",
].join("\n");
// parseMusicSpec from core/music.js (without continuation lines)
function parseMusicSpec(text) {
const spec = {};
for (const raw of text.split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const i = line.indexOf(":");
if (i < 0) continue;
spec[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
}
return spec;
}
const spec = parseMusicSpec(receta);
console.log(spec);
console.log("chords:", spec.progression.split(" "));
- The top comment (
#) is skipped; the rest becomes an object of strings. i VI III VIIare scale degrees in Roman numerals: the root chord, then the sixth, third and seventh of the minor scale.AABA: section A plays twice, then a B, then A again, as in so many songs.
From "C4" to hertz
To do math with notes, the game numbers them as MIDI: each semitone is a number. C4 (middle C) is 60 and A4 (the tuning A) is 69. parseNoteName does the conversion: 12 times the octave plus one, plus the note's position within the octave.
From number to frequency there's a one-line formula: 440 · 2^((n − 69) / 12). Each semitone multiplies the frequency by the twelfth root of 2, so going up 12 (an octave) doubles it.
const NOTE_IDX = { C: 0, "C#": 1, DB: 1, D: 2, "D#": 3, EB: 3, E: 4, F: 5, "F#": 6, GB: 6, G: 7, "G#": 8, AB: 8, A: 9, "A#": 10, BB: 10, B: 11 };
function parseNoteName(s) {
const m = /^([A-G](?:#|B)?)(-?\d)$/.exec(s.trim().toUpperCase());
if (!m) throw new Error("Invalid note: " + s);
return 12 * (parseInt(m[2]) + 1) + NOTE_IDX[m[1]];
}
const midiToFreq = (n) => 440 * Math.pow(2, (n - 69) / 12);
for (const nombre of ["C4", "E4", "G4", "A4", "C5", "A5", "A3"]) {
const n = parseNoteName(nombre);
console.log(nombre.padEnd(3), "midi", n, "->", midiToFreq(n).toFixed(2), "Hz");
}
A4gives exactly 440: it's the formula's anchor.A5gives 880 andA3, 220: an octave up doubles it and an octave down halves it.C4,E4andG4(60, 64, 67) make the C major chord.
The seed writes the melody
generateSong uses the scale to build a pool of allowed notes (two octaves of the minor pentatonic from C4) and walks through it: each step moves up or down up to two places at random, sometimes rests and sometimes holds the previous note. On strong beats it looks for a note of the current chord, so it doesn't clash.
The randomness is mulberry32 with the day's music seed. Here's a reduced version (no chords or sections): run it and notice the same seed gives the same melody.
function mulberry32(seed) {
let a = seed >>> 0;
const next = () => {
a = (a + 0x6D2B79F5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return { next, int: (min, max) => min + Math.floor(next() * (max - min + 1)) };
}
const NOMBRES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
const nombre = (n) => NOMBRES[n % 12] + (Math.floor(n / 12) - 1);
// Two octaves of the minor pentatonic from C4 (midi 60)
const ESCALA = [0, 3, 5, 7, 10];
const permitidas = [];
for (let o = 0; o < 2; o++) for (const s of ESCALA) permitidas.push(60 + 12 * o + s);
function melodia(semilla, pasos) {
const rng = mulberry32(semilla);
let cur = Math.floor(permitidas.length / 2);
const notas = [];
for (let i = 0; i < pasos; i++) {
const r = rng.next();
if (r < 0.12) { notas.push("."); continue; } // rest
if (r < 0.30) { notas.push("-"); continue; } // hold the previous one
cur = Math.min(permitidas.length - 1, Math.max(0, cur + rng.int(-2, 2)));
notas.push(nombre(permitidas[cur]));
}
return notas.join(" ");
}
console.log("allowed:", permitidas.map(nombre).join(" "));
console.log("seed 1:", melodia(1, 16));
console.log("seed 1:", melodia(1, 16));
console.log("seed 2:", melodia(2, 16));
- The melody never leaves the ten allowed notes: that's why, random as it is, it sounds "like something".
.is a rest and-holds the previous note.- Both seed-1 lines match note for note. In the game that seed comes from the date (unit 5), so everyone hears the same song that day.
A sequencer that never falls behind
The browser doesn't promise that a 25 ms setInterval wakes every 25 ms: sometimes it's late because the phone was busy drawing. If each note played "whenever the timer wakes", the music would limp.
core/audio.js's Sequencer uses a well-known trick called *lookahead*: each time it wakes it schedules every note in the next 120 ms at its exact time on the AudioContext clock (osc.start(t)), which is precise. Waking late doesn't matter as long as it's before that window.
const tempo = 160;
const pasoDur = 60 / tempo / 2; // one eighth note, in seconds
let siguiente = 0.05, paso = 0;
// When the 25 ms setInterval really woke up (never on time)
const despertares = [0, 0.031, 0.049, 0.09, 0.101, 0.16, 0.183, 0.26, 0.29, 0.31,
0.352, 0.37, 0.43, 0.47, 0.49, 0.53, 0.58, 0.61, 0.66, 0.7, 0.74, 0.81];
let enBlanco = 0;
for (const ahora of despertares) {
const programadas = [];
while (siguiente < ahora + 0.12) {
programadas.push("step " + paso + " at " + siguiente.toFixed(4) + " s");
paso++;
siguiente += pasoDur;
}
if (programadas.length) console.log("wakes at", ahora.toFixed(3), "->", programadas.join(", "));
else enBlanco++;
}
console.log("wake-ups with nothing to do:", enBlanco, "of", despertares.length);
The wake-ups are uneven, but the steps land exactly 0.1875 s apart (an eighth note at 160 bpm). Most of the time the sequencer wakes and has nothing to schedule: it already set it up earlier.