Synthétiseur de sonnerie Nokia Composer en 512 octets

Un peu de nostalgie dans notre nouvelle traduction - essayer d'écrire Nokia Composer et composer notre propre mélodie.


L'un de vos lecteurs a-t-il utilisé un ancien Nokia, par exemple, les modèles 3310 ou 3210? Vous devez vous rappeler sa grande fonctionnalité - la possibilité de composer vos propres sonneries directement sur le clavier du téléphone. En organisant les notes et les pauses dans l'ordre souhaité, vous pouvez jouer une mélodie populaire à partir du haut-parleur du téléphone et même partager la création avec des amis! Si vous avez raté cette époque, voici à quoi elle ressemblait:







N'a pas impressionné? Croyez-moi, ça sonnait vraiment cool à l'époque, surtout pour ceux qui aimaient la musique.



La notation musicale (notation musicale) et le format utilisés dans Nokia Composer sont connus sous le nom de RTTTL (Ring Tone Text Transfer Language). RTTL est encore largement utilisé par les amateurs pour jouer des mélodies monophoniques sur Arduino, etc.



RTTTL vous permet d'écrire de la musique pour une seule voix, les notes ne peuvent être jouées que séquentiellement, sans accords ni polyphonie. Cependant, cette limitation s'est avérée être une fonctionnalité qui tue, car un tel format est facile à écrire et à lire, facile à analyser et à reproduire.



Dans cet article, nous allons essayer de créer un lecteur RTTTL en JavaScript, en ajoutant un peu de code de golf et de mathématiques pour garder le code aussi court que possible pour le plaisir.



Analyser RTTTL



Pour RTTTL, une grammaire formelle est utilisée. Le format RTTL est une chaîne composée de trois parties: le nom de la mélodie, ses caractéristiques, telles que le tempo (BPM - battements par minute, c'est-à-dire le nombre de battements par minute), l'octave et la durée de la note, ainsi que le code de la mélodie lui-même. Cependant, nous simulerons le comportement de Nokia Composer lui-même, analyserons seulement une partie de la mélodie et considérerons le tempo BPM comme un paramètre d'entrée distinct. Le nom de la mélodie et ses caractéristiques de service sont laissés en dehors de la portée de cet article.



Une mélodie est simplement une séquence de notes / silences, séparées par des virgules avec des espaces supplémentaires. Chaque note se compose d'une longueur (2/4/8/16/32/64), d'une hauteur (c / d / e / f / g / a / b), éventuellement d'un dièse (#) et du nombre d'octaves (de 1 à 3 car seules trois octaves sont prises en charge).



Le moyen le plus simple est d'utiliser des expressions régulières . Les navigateurs plus récents sont livrés avec une fonction matchAll très pratique qui renvoie un ensemble de toutes les correspondances dans une chaîne:



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
  }
};
      
      





La première chose à comprendre à propos de chaque note est de savoir comment la convertir en fréquence des ondes sonores. Bien sûr, nous pouvons créer un HashMap pour les sept lettres de note. Mais comme ces lettres sont dans l'ordre, il devrait être plus facile de les considérer comme des nombres. Pour chaque lettre de note, nous trouvons le code de caractère numérique correspondant (code ASCII ). Pour "A", ce sera 0x41 et pour "a" ce sera 0x61. Pour "B / b", ce sera 0x42 / 0x62, pour "C / c" ce sera 0x43 / 0x63, et ainsi de suite:



// 'k' is an ASCII code of the note:
// A..G = 0x41..0x47
// a..g = 0x61..0x67
let k = m[4].charCodeAt();
      
      





Nous devrions probablement sauter les bits les plus significatifs, nous n'utiliserons que k & 7 comme index de note (a = 1, c = 2,…, g = 7). Et après? La prochaine étape n'est pas très agréable, car elle est liée au solfège. Si nous n'avons que 7 notes, alors nous les comptons comme toutes les 12. C'est parce que les notes aiguës / plates sont inégalement cachées entre les notes habituelles:



         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
      
      





Comme vous pouvez le voir, l'index de note en octave augmente plus rapidement que le code de note (k & 7). De plus, il augmente de manière non linéaire: la distance entre E et F ou entre B et C est de 1 demi-ton, et non de 2, comme entre le reste des notes.



Intuitivement, nous pouvons essayer de multiplier (k & 7) par 12/7 (12 demi-tons et 7 notes):



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 nous regardons ces nombres sans les décimales, nous remarquerons immédiatement qu'ils ne sont pas linéaires, comme nous nous y attendions:



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
                                  -------
      
      





Mais pas vraiment ... L'espacement "demi-teinte" doit être entre B / C et E / F, pas entre C / D. Essayons d'autres rapports (les traits de soulignement indiquent les demi-tons):



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
                         -------     -------      -------
      
      





Il est clair que les valeurs 1,8 et 1,5 ne conviennent pas: le premier n'a qu'un demi-ton, et le second en a trop. Les deux autres, 1.6 et 1.7, semblent bien nous convenir: 1.7 donne l'échelle majeure GA-BC-D-EF, et 1.6 donne l'échelle majeure AB-CD-EFG. Juste ce dont nous avons besoin!



Maintenant, nous devons changer un peu les valeurs pour que C soit 0, D soit 2, E soit 4, F soit 5, et ainsi de suite. Nous devrions être décalés de 4 demi-tons, mais soustraire 4 rendra la note A sous la note C, donc à la place nous ajoutons 8 et calculons le modulo 12 si la valeur est hors d'une octave:



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 ...
      
      





Nous devons également prendre en compte le caractère "sharp", qui est capturé par le groupe m [3] de l'expression régulière. S'il est présent, augmentez la valeur de la note d'un demi-ton:



// 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];

      
      





Enfin, nous devons utiliser l'octave correcte. Les octaves sont déjà stockées sous forme de nombres dans le groupe d'expressions régulières m [5]. Selon la théorie musicale, chaque octave est de 12 Séminots, nous pouvons donc multiplier le nombre d'octave par 12 et ajouter à la valeur de la note:



// 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
      
      





Serrage



Que se passe-t-il si quelqu'un indique le nombre d'octaves comme 10 ou 1000? Cela peut conduire à une échographie! Nous ne devrions autoriser que le jeu de valeurs correct pour ces paramètres. La limitation du nombre entre les deux autres est communément appelée "serrage". Modern JS a une fonction spéciale Math.clamp (x, low, high) , qui, cependant, n'est pas encore disponible dans la plupart des navigateurs. L'alternative la plus simple consiste à utiliser:



clamp = (x, a, b) => Math.max(Math.min(x, b), a);
      
      





Mais puisque nous essayons de garder notre code aussi court que possible, nous pouvons réinventer la roue et arrêter d'utiliser les fonctions mathématiques. Nous utilisons la valeur par défaut x = 0 pour faire fonctionner le serrage avec des valeurs non définies :



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
      
      





Notez le tempo et la durée



Nous nous attendons à ce que le BPM soit passé en paramètre à la fonction out play () . Il suffit de le valider:



bpm = clamp(bpm, 40, 400);
      
      





Maintenant, pour calculer la durée d'une note en secondes, nous pouvons obtenir sa durée musicale (entière / moitié / quart /…), qui est stockée dans le groupe regex m [1]. Nous utilisons la formule suivante:



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 nous combinons ces formules en une seule et limitons la durée de la note, nous obtenons:



// Assuming that default note duration is 4:
duration = 240 / bpm / clamp(m[1] || 4, 1, 64);
      
      





N'oubliez pas non plus la possibilité de spécifier des notes avec des points, ce qui augmente la longueur de la note actuelle de 50%. Nous avons un groupe m [2], dont la valeur peut être un point . ou indéfini . En appliquant la même méthode que nous avons utilisée précédemment pour le signe aigu, nous obtenons:



// !!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);
      
      





Nous pouvons maintenant calculer le nombre et la durée de chaque note. Il est temps d'utiliser l'API WebAudio pour jouer un morceau.



WEBAUDIO



Nous n'avons besoin que de 3 parties de toute l' API WebAudio : un contexte audio, un oscillateur pour traiter l'onde sonore et un nœud de gain pour activer / désactiver le son. J'utiliserai un oscillateur rectangulaire pour faire sonner la mélodie comme ce terrible vieux téléphone qui sonne:



// 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();
      
      





Ce code en lui-même ne créera pas encore de musique, mais puisque nous avons analysé notre mélodie RTTTL, nous pouvons dire à WebAudio quelle note jouer, quand, avec quelle fréquence et pendant combien de temps.



Tous les nœuds WebAudio ont une méthode setValueAtTime spéciale qui planifie un événement de changement de valeur (fréquence ou gain de nœud).



Si vous vous souvenez, plus tôt dans l'article, nous avions déjà le code ASCII de la note stocké en k, l'index de la note en n, et nous avions la durée de la note en secondes. Maintenant, pour chaque note, nous pouvons faire ce qui suit:



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);
}
      
      





C'est tout. Notre programme play () peut maintenant jouer des mélodies entières écrites en notation RTTTL. Voici le code complet, avec des clarifications mineures telles que l'utilisation de v comme raccourci pour setValueAtTime ou l'utilisation de variables à une lettre (C = contexte, z = oscillateur car il produit un son similaire, g = gain, q = bpm, c = pince):



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);
      
      





Lorsqu'il est minifié avec terser, ce code ne fait que 417 octets. C'est toujours en dessous du seuil de 512 octets. Pourquoi n'ajoutons-nous pas une fonction stop () pour interrompre la lecture:



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 :)
      
      





C'est encore autour de 445 octets. Si vous collez ce code dans la console développeur, vous pouvez lire le RTTTL et arrêter la lecture en appelant les fonctions JS play () et stop () .



UI



Je pense que l'ajout d'une petite interface utilisateur à notre synthétiseur rendra le moment de faire de la musique encore plus agréable. À ce stade, je suggérerais d'oublier le code de golf. Il est possible de créer un petit éditeur pour les sonneries RTTTL sans enregistrer d'octets en utilisant du HTML et du CSS normaux et en incluant un script minifié en lecture seule.



J'ai décidé de ne pas publier le code ici car c'est assez ennuyeux. Vous pouvez le trouver sur github . Vous pouvez également essayer la version de démonstration ici: https://zserge.com/nokia-composer/ .







Si la muse vous a quitté et que vous n'avez pas du tout envie d'écrire de la musique, essayez quelques chansons existantes et appréciez le son familier:





Au fait, si vous avez réellement composé quelque chose, partagez l'URL (toutes les chansons et tous les BPM sont stockés dans la partie hachée de l'url, donc enregistrer / partager vos chansons est aussi simple que de copier ou de mettre le lien dans vos favoris.



J'espère que vous l'avez apprécié. Voir cet article Vous pouvez suivre l'actualité sur Github , Twitter ou vous abonner via rss .



All Articles