← Así se hace un juego: Snake de Daily Challenge · Lección 12/16
Música desde una receta
Una canción en once renglones
La música de cada reto sale de una receta: un archivo de texto en public/content/music/ con renglones clave: valor. Una receta no trae notas: trae las reglas (tempo, tónica, escala, progresión de acordes, estructura, ondas) y la semilla del día escribe la melodía. Así hay una canción distinta cada día sin componer ninguna.
Hay otro tipo, el tema fijo (tune:), con piezas de dominio público escritas nota por nota; ahí la semilla sólo elige el bajo y la batería.
El programa lee la receta chip_01.txt con el parseMusicSpec del juego, resumido.
$ node leccion.js
Pulsa CORRER (o Ctrl+Enter).
Qué mirar
- El comentario de arriba (
#) se ignora; lo demás queda como un objeto de textos. i VI III VIIson grados en números romanos: el acorde de la tónica y luego el sexto, el tercero y el séptimo de la escala menor.AABA: la sección A suena dos veces, luego una B y vuelve la A, como en tantas canciones.
Toda la lección en una página
Una canción en once renglones
La música de cada reto sale de una receta: un archivo de texto en public/content/music/ con renglones clave: valor. Una receta no trae notas: trae las reglas (tempo, tónica, escala, progresión de acordes, estructura, ondas) y la semilla del día escribe la melodía. Así hay una canción distinta cada día sin componer ninguna.
Hay otro tipo, el tema fijo (tune:), con piezas de dominio público escritas nota por nota; ahí la semilla sólo elige el bajo y la batería.
El programa lee la receta chip_01.txt con el parseMusicSpec del juego, resumido.
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 de core/music.js (sin los renglones que continúan el anterior)
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("acordes:", spec.progression.split(" "));
- El comentario de arriba (
#) se ignora; lo demás queda como un objeto de textos. i VI III VIIson grados en números romanos: el acorde de la tónica y luego el sexto, el tercero y el séptimo de la escala menor.AABA: la sección A suena dos veces, luego una B y vuelve la A, como en tantas canciones.
De «C4» a hercios
Para hacer cuentas con notas, el juego las numera como MIDI: cada semitono es un número. C4 (el do central) es 60 y A4 (el la de afinar) es 69. parseNoteName hace la conversión: 12 por la octava más uno, más la posición de la nota dentro de la octava.
De número a frecuencia hay una fórmula de un renglón: 440 · 2^((n − 69) / 12). Cada semitono multiplica la frecuencia por la raíz doceava de 2, así que subir 12 (una octava) la duplica.
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("Nota inválida: " + 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");
}
A4da 440 justos: es el punto de partida de la fórmula.A5da 880 yA3, 220: una octava arriba es el doble y una abajo, la mitad.C4,E4yG4(60, 64, 67) son el acorde de do mayor.
La semilla escribe la melodía
generateSong arma con la escala un juego de notas permitidas (dos octavas de la pentatónica menor desde C4) y camina por él: en cada paso sube o baja hasta dos lugares al azar, a veces calla y a veces sostiene la nota anterior. En los tiempos fuertes busca una nota del acorde que suena, para que no desafine.
El azar es mulberry32 con la semilla de la música del día. Aquí va una versión reducida (sin acordes ni secciones): corre el programa y fíjate en que la misma semilla da la misma melodía.
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);
// Dos octavas de la pentatónica menor desde 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; } // silencio
if (r < 0.30) { notas.push("-"); continue; } // sostener la anterior
cur = Math.min(permitidas.length - 1, Math.max(0, cur + rng.int(-2, 2)));
notas.push(nombre(permitidas[cur]));
}
return notas.join(" ");
}
console.log("permitidas:", permitidas.map(nombre).join(" "));
console.log("semilla 1:", melodia(1, 16));
console.log("semilla 1:", melodia(1, 16));
console.log("semilla 2:", melodia(2, 16));
- La melodía nunca sale de las diez notas permitidas: por eso, aunque sea al azar, suena «a algo».
.es silencio y-sostiene la nota anterior.- Las dos líneas de la semilla 1 coinciden nota por nota. En el juego, esa semilla sale de la fecha (unidad 5), así que todos oyen la misma canción ese día.
Un secuenciador que no se atrasa
El navegador no garantiza que un setInterval de 25 ms despierte cada 25 ms: a veces llega tarde porque el teléfono estaba ocupado dibujando. Si cada nota sonara «cuando el temporizador despierta», la música cojearía.
El Sequencer de core/audio.js usa un truco conocido como *lookahead*: cada vez que despierta, programa todas las notas de los próximos 120 ms con su hora exacta en el reloj del AudioContext (osc.start(t)), que es preciso. Despertar tarde no importa mientras sea antes de esa ventana.
const tempo = 160;
const pasoDur = 60 / tempo / 2; // una corchea, en segundos
let siguiente = 0.05, paso = 0;
// Cuándo despertó de verdad el setInterval de 25 ms (nunca puntual)
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("paso " + paso + " a " + siguiente.toFixed(4) + " s");
paso++;
siguiente += pasoDur;
}
if (programadas.length) console.log("despierta en", ahora.toFixed(3), "->", programadas.join(", "));
else enBlanco++;
}
console.log("despertares sin nada que hacer:", enBlanco, "de", despertares.length);
Los despertares están desparejos, pero los pasos quedan a exactamente 0.1875 s uno del otro (una corchea a 160 por minuto). La mayoría de las veces el secuenciador despierta y no hay nada que programar: ya lo dejó listo antes.