Un poco de nostalgia en nuestra nueva traducción: intentar escribir Nokia Composer y componer nuestra propia melodía.
¿Alguno de sus lectores utilizó un Nokia antiguo, por ejemplo, los modelos 3310 o 3210? Debe recordar su gran característica: la capacidad de componer sus propios tonos de llamada directamente en el teclado del teléfono. Al organizar las notas y las pausas en el orden deseado, puede reproducir una melodía popular desde el altavoz del teléfono e incluso compartir la creación con sus amigos. Si te perdiste esa época, así es como se veía:
¿No impresionó? Créame, sonaba realmente genial en ese entonces, especialmente para aquellos que estaban interesados en la música.
La notación musical (notación musical) y el formato utilizado en Nokia Composer se conoce como RTTTL (Idioma de transferencia de texto de tonos de llamada). RTTL todavía es ampliamente utilizado por los aficionados para tocar melodías monofónicas en Arduino, etc.
RTTTL le permite escribir música para una sola voz, las notas solo se pueden tocar secuencialmente, sin acordes ni polifonía. Sin embargo, esta limitación resultó ser una característica espectacular, ya que dicho formato es fácil de escribir y leer, fácil de analizar y reproducir.
En este artículo, intentaremos crear un reproductor RTTTL en JavaScript, agregando un poco de código de golf y matemáticas para mantener el código lo más corto posible para divertirnos.
Analizando RTTTL
Para RTTTL, se usa una gramática formal. El formato RTTL es una cuerda que consta de tres partes: el nombre de la melodía, sus características, como el tempo (BPM - beats por minuto, es decir, el número de beats por minuto), la octava y la duración de la nota, así como el código de la melodía en sí. Sin embargo, simularemos el comportamiento del propio Nokia Composer, analizaremos solo una parte de la melodía y consideraremos el tempo de BPM como un parámetro de entrada independiente. El nombre de la melodía y sus características de servicio quedan fuera del alcance de este artículo.
Una melodía es simplemente una secuencia de notas / silencios separados por comas con espacios adicionales. Cada nota consta de una longitud (2/4/8/16/32/64), un tono (c / d / e / f / g / a / b), opcionalmente un sostenido (#) y el número de octavas (desde 1 a 3 ya que solo se admiten tres octavas).
La forma más sencilla es utilizar expresiones regulares . Los navegadores más nuevos vienen con una función matchAll muy útil que devuelve un conjunto de todas las coincidencias en una cadena:
const play = s => {
for (m of s.matchAll(/(\d*)?(\.?)(#?)([a-g-])(\d*)/g)) {
// m[1] is optional note duration
// m[2] is optional dot in note duration
// m[3] is optional sharp sign, yes, it goes before the note
// m[4] is note itself
// m[5] is optional octave number
}
};
Lo primero que debe averiguar sobre cada nota es cómo convertirla a la frecuencia de ondas sonoras. Por supuesto, podemos crear un HashMap para las siete letras de notas. Pero dado que estas letras están en secuencia, debería ser más fácil pensar en ellas como números. Para cada letra-nota, encontramos el código de carácter numérico correspondiente (código ASCII ). Para "A" será 0x41, y para "a" será 0x61. Para "B / b" será 0x42 / 0x62, para "C / c" será 0x43 / 0x63, y así sucesivamente:
// 'k' is an ASCII code of the note:
// A..G = 0x41..0x47
// a..g = 0x61..0x67
let k = m[4].charCodeAt();
Probablemente deberíamos omitir los bits más significativos, solo usaremos k & 7 como índice de nota (a = 1, c = 2,…, g = 7). ¿Que sigue? La siguiente etapa no es muy agradable, ya que está relacionada con la teoría musical. Si solo tenemos 7 notas, las contamos como las 12. Esto se debe a que las notas sostenidas / bemoles están ocultas de manera desigual entre las notas habituales:
A# C# D# F# G# A# <- black keys
A B | C D E F G A B | C <- white keys
--------+------------------------------------+---
k&7: 1 2 | 3 4 5 6 7 1 2 | 3
--------+------------------------------------+---
note: 9 10 11 | 0 1 2 3 4 5 6 7 8 9 10 11 | 0
Como puede ver, el índice de nota en octava aumenta más rápido que el código de nota (k y 7). Además, aumenta de forma no lineal: la distancia entre E y F o entre B y C es 1 semitono, no 2, como entre el resto de notas.
Intuitivamente, podemos intentar multiplicar (k & 7) por 12/7 (12 semitonos y 7 notas):
note: a b c d e f g (k&7)*12/7: 1.71 3.42 5.14 6.85 8.57 10.28 12.0
Si miramos estos números sin los lugares decimales, notaremos inmediatamente que no son lineales, como esperábamos:
note: a b c d e f g (k&7)*12/7: 1.71 3.42 5.14 6.85 8.57 10.28 12.0 floor((k&7)*12/7): 1 3 5 6 8 10 12 -------
Pero no realmente ... La distancia del "medio tono" debe estar entre B / C y E / F, no entre C / D. Probemos con otras proporciones (los guiones bajos indican semitonos):
note: a b c d e f g floor((k&7)*1.8): 1 3 5 7 9 10 12 -------- floor((k&7)*1.7): 1 3 5 6 8 10 11 ------- -------- floor((k&7)*1.6): 1 3 4 6 8 9 11 ------- -------- floor((k&7)*1.5): 1 3 4 6 7 9 10 ------- ------- -------
Está claro que los valores 1.8 y 1.5 no son adecuados: el primero tiene solo un semitono y el segundo tiene demasiados. Los otros dos, 1.6 y 1.7, parecen encajar bien con nosotros: 1.7 da la escala mayor GA-BC-D-EF, y 1.6 da la escala mayor AB-CD-EFG. ¡Justo lo que necesitamos!
Ahora necesitamos cambiar un poco los valores para que C sea 0, D sea 2, E sea 4, F sea 5 y así sucesivamente. Deberíamos estar compensados por 4 semitonos, pero restar 4 hará que la nota A debajo de la nota C, así que en su lugar agregamos 8 y calculamos módulo 12 si el valor está fuera de una octava:
let n = (((k&7) * 1.6) + 8) % 12;
// A B C D E F G A B C ...
// 9 11 0 2 4 5 7 9 11 0 ...
También tenemos que tener en cuenta el carácter "agudo", que es capturado por el grupo m [3] de la expresión regular. Si está presente, aumente el valor de la nota en 1 semitono:
// we use !!m[3], if m[3] is '#' - that would evaluate to `true`
// and gets converted to `1` because of the `+` sign.
// If m[3] is undefined - it turns into `false` and, thus, into `0`:
let n = (((k&7) * 1.6) + 8)%12 + !!m[3];
Finalmente, debemos usar la octava correcta. Las octavas ya están almacenadas como números en el grupo de expresiones regulares m [5]. Según la teoría musical, cada octava son 12 Seminots, por lo que podemos multiplicar el número de octava por 12 y agregar al valor de la nota:
// n is a note index 0..35 where 0 is C of the lowest octave,
// 12 is C of the middle octave and 35 is B of the highest octave.
let n =
(((k&7) * 1.6) + 8)%12 + // note index 0..11
!!m[3] + // semitote 0/1
m[5] * 12; // octave number
Reprimición
¿Qué sucede si alguien indica el número de octavas como 10 o 1000? ¡Esto puede provocar una ecografía! Solo debemos permitir el conjunto correcto de valores para dichos parámetros. Limitar el número entre los otros dos se denomina comúnmente "sujeción". Modern JS tiene una función especial Math.clamp (x, low, high) , que, sin embargo, aún no está disponible en la mayoría de los navegadores. La alternativa más sencilla es utilizar:
clamp = (x, a, b) => Math.max(Math.min(x, b), a);
Pero debido a que estamos tratando de mantener nuestro código lo más pequeño posible, podemos reinventar la rueda y dejar de usar funciones matemáticas. Usamos el valor predeterminado x = 0 para hacer que la sujeción funcione también con valores indefinidos :
clamp = (x=0, a, b) => (x < a && (x = a), x > b ? b : x); clamp(0, 1, 3) // => 1 clamp(2, 1, 3) // => 2 clamp(8, 1, 3) // => 3 clamp(undefined, 1, 3) // => 1
Tempo y duración de la nota
Esperamos que BPM se pase como parámetro a la función out play () . Solo tenemos que validarlo:
bpm = clamp(bpm, 40, 400);
Ahora, para calcular cuánto debe durar una nota en segundos, podemos obtener su duración musical (entero / medio / cuarto /…), que se almacena en el grupo regex m [1]. Usamos la siguiente fórmula:
note_duration = m[1]; // can be 1,2,4,8,16,32,64
// since BPM is "beats per minute", or usually "quarter note beats per minute",
// BPM/4 would be "whole notes per minute" and BPM/60/4 would be "whole
// notes per second":
whole_notes_per_second = bpm / 240;
duration = 1 / (whole_notes_per_second * note_duration);
Si combinamos estas fórmulas en una y limitamos la duración de la nota, obtenemos:
// Assuming that default note duration is 4: duration = 240 / bpm / clamp(m[1] || 4, 1, 64);
Además, no se olvide de la capacidad de especificar notas con puntos, que aumentan la duración de la nota actual en un 50%. Tenemos un grupo m [2], cuyo valor puede ser un punto . o indefinido . Aplicando el mismo método que usamos anteriormente para el signo agudo, obtenemos:
// !!m[2] would be 1 if it's a dot, 0 otherwise
// 1+!![m2]/2 would be 1 for normal notes and 1.5 for dotted notes
duration = 240 / bpm / clamp(m[1] || 4, 1, 64) * (1+!!m[2]/2);
Ahora podemos calcular el número y la duración de cada nota. Es hora de usar la API de WebAudio para tocar una melodía.
WEBAUDIO
Solo necesitamos 3 partes de toda la API de WebAudio : contexto de audio, un oscilador para procesar la onda de sonido y un nodo de ganancia para encender / apagar el sonido. Usaré un oscilador rectangular para hacer que la melodía suene como ese horrible teléfono viejo sonando:
// Osc -> Gain -> AudioContext
let audio = new (AudioContext() || webkitAudioContext);
let gain = audio.createGain();
let osc = audio.createOscillator();
osc.type = 'square';
osc.connect(gain);
gain.connect(audio.destination);
osc.start();
Este código por sí solo no creará música todavía, pero como analizamos nuestra melodía RTTTL, podemos decirle a WebAudio qué nota tocar, cuándo, con qué frecuencia y durante cuánto tiempo.
Todos los nodos de WebAudio tienen un método setValueAtTime especial que programa un evento de cambio de valor (frecuencia o ganancia de nodo).
Si recuerda, anteriormente en el artículo ya teníamos el código ASCII para la nota almacenada como k, el índice de nota como n, y teníamos la duración de la nota en segundos. Ahora, para cada nota, podemos hacer lo siguiente:
t = 0; // current time counter, in seconds
for (m of ......) {
// ....we parse notes here...
// Note frequency is calculated as (F*2^(n/12)),
// Where n is note index, and F is the frequency of n=0
// We can use C2=65.41, or C3=130.81. C2 is a bit shorter.
osc.frequency.setValueAtTime(65.4 * 2 ** (n / 12), t);
// Turn on gain to 100%. Besides notes [a-g], `k` can also be a `-`,
// which is a rest sign. `-` is 0x2d in ASCII. So, unlike other note letters,
// (k&8) would be 0 for notes and 8 for rest. If we invert `k`, then
// (~k&8) would be 8 for notes and 0 for rest. Shifing it by 3 would be
// ((~k&8)>>3) = 1 for notes and 0 for rests.
gain.gain.setValueAtTime((~k & 8) >> 3, t);
// Increate the time marker by note duration
t = t + duration;
// Turn off the note
gain.gain.setValueAtTime(0, t);
}
Es todo. Nuestro programa play () ahora puede reproducir melodías completas escritas en notación RTTTL. Aquí está el código completo, con algunas aclaraciones menores, como usar v como atajo para setValueAtTime o usar variables de una letra (C = contexto, z = oscilador porque produce un sonido similar, g = ganancia, q = bpm, c = abrazadera):
c = (x=0,a,b) => (x<a&&(x=a),x>b?b:x); // clamping function (a<=x<=b)
play = (s, bpm) => {
C = new AudioContext;
(z = C.createOscillator()).connect(g = C.createGain()).connect(C.destination);
z.type = 'square';
z.start();
t = 0;
v = (x,v) => x.setValueAtTime(v, t); // setValueAtTime shorter alias
for (m of s.matchAll(/(\d*)?(\.?)([a-g-])(#?)(\d*)/g)) {
k = m[4].charCodeAt(); // note ASCII [0x41..0x47] or [0x61..0x67]
n = 0|(((k&7) * 1.6)+8)%12+!!m[3]+12*c(m[5],1,3); // note index [0..35]
v(z.frequency, 65.4 * 2 ** (n / 12));
v(g.gain, (~k & 8) / 8);
t = t + 240 / bpm / (c(m[1] || 4, 1, 64))*(1+!!m[2]/2);
v(g.gain, 0);
}
};
// Usage:
play('8c 8d 8e 8f 8g 8a 8b 8c2', 120);
Cuando se minimiza con terser, este código tiene solo 417 bytes. Esto todavía está por debajo del umbral de 512 bytes. ¿Por qué no agregamos una función stop () para interrumpir la reproducción?
C=0; // initialize audio conteext C at the beginning with zero
stop = _ => C && C.close(C=0);
// using `_` instead of `()` for zero-arg function saves us one byte :)
Esto todavía es alrededor de 445 bytes. Si pega este código en la consola del desarrollador, puede reproducir RTTTL y detener la reproducción llamando a las funciones de JS play () y stop () .
UI
Creo que agregar una pequeña interfaz de usuario a nuestro sintetizador hará que el momento de hacer música sea aún más agradable. En este punto, sugiero que se olvide del código de golf. Es posible crear un pequeño editor para tonos de llamada RTTTL sin guardar bytes usando HTML y CSS normal e incluyendo un script minificado de solo reproducción.
Decidí no publicar el código aquí porque es bastante aburrido. Puedes encontrarlo en github . También puede probar la versión de demostración aquí: https://zserge.com/nokia-composer/ .
Si la musa te ha dejado y no tienes ganas de escribir música en absoluto, prueba algunas canciones existentes y disfruta del sonido familiar:
- tono de llamada Nokia
- tono de llamada de iPhone si te gusta más la música moderna
- Enciende mi fuego
- Perderse
- Lo bueno, lo malo y lo feo
- Rondo Alla Turca (Mozart)
Por cierto, si realmente compusiste algo, comparte la URL (todas las canciones y BPM se almacenan en la parte hash de la URL, por lo que guardar / compartir tus canciones es tan fácil como copiar o marcar el enlace como favorito.
Espero que lo hayas disfrutado. Vea este artículo Puede seguir las noticias en Github , Twitter o suscribirse a través de rss .