Nokia Composer Ringtone Synthesizer em 512 bytes

Um pouco de nostalgia em nossa nova tradução - tentando escrever Nokia Composer e compor nossa própria melodia.


Algum dos seus leitores usava um Nokia antigo, por exemplo, modelos 3310 ou 3210? Você deve se lembrar de seu excelente recurso - a capacidade de compor seus próprios toques diretamente no teclado do telefone. Organizando as notas e as pausas na ordem desejada, você pode tocar uma melodia popular no alto-falante do telefone e até mesmo compartilhar a criação com os amigos! Se você perdeu essa era, é assim que parecia:







Não impressionou? Confie em mim, parecia muito legal naquela época, especialmente para quem gostava de música.



A notação musical (notação musical) e o formato usados ​​no Nokia Composer são conhecidos como RTTTL (Ring Tone Text Transfer Language). O RTTL ainda é amplamente utilizado por amadores para tocar melodias monofônicas no Arduino, etc. O



RTTTL permite escrever música para apenas uma voz, as notas só podem ser tocadas sequencialmente, sem acordes e polifonia. No entanto, essa limitação acabou por ser um recurso matador, já que tal formato é fácil de escrever e ler, fácil de analisar e reproduzir.



Neste artigo, tentaremos criar um reprodutor JavaScript RTTTL, adicionando um pouco de código de golfe e matemática para se divertir para manter o código o mais curto possível.



Analisando RTTTL



Para RTTTL, uma gramática formal é usada. O formato RTTL é uma corda composta por três partes: o nome da melodia, suas características, como o tempo (BPM - batidas por minuto, ou seja, o número de batidas por minuto), oitava e duração da nota, além do próprio código da melodia. No entanto, vamos simular o comportamento do próprio Nokia Composer, analisar apenas uma parte da melodia e considerar o tempo do BPM como um parâmetro de entrada separado. O nome da melodia e suas características de serviço não são incluídos no escopo deste artigo.



Uma melodia é simplesmente uma sequência de notas / pausas, separadas por vírgulas com espaços adicionais. Cada nota consiste em um comprimento (2/4/8/16/32/64), um pitch (c / d / e / f / g / a / b), opcionalmente um sustenido (#) e o número de oitavas (a partir de 1 a 3, já que apenas três oitavas são suportadas).



A maneira mais fácil é usar expressões regulares . Os navegadores mais recentes vêm com uma função matchAll muito útil que retorna um conjunto de todas as correspondências em uma string:



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





A primeira coisa a descobrir sobre cada nota é como convertê-la na frequência das ondas sonoras. Claro, podemos criar um HashMap para todas as sete letras de notas. Mas, como essas letras estão em sequência, deve ser mais fácil pensar nelas como números. Para cada nota de letra, encontramos o código de caractere numérico correspondente (código ASCII ). Para "A", será 0x41 e para "a" será 0x61. Para "B / b" será 0x42 / 0x62, para "C / c" será 0x43 / 0x63 e assim por diante:



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





Provavelmente deveríamos pular os bits mais significativos, usaremos apenas k & 7 como o índice da nota (a = 1, c = 2,…, g = 7). Qual é o próximo? A próxima etapa não é muito agradável, pois está relacionada à teoria musical. Se tivermos apenas 7 notas, então as contamos como todas as 12. Isso ocorre porque as notas sustenidas / bemol estão desigualmente escondidas entre as notas usuais:



         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 você pode ver, o índice da nota na oitava aumenta mais rápido do que o código da nota (k e 7). Além disso, aumenta de forma não linear: a distância entre E e F ou entre B e C é de 1 semitom, não 2, como entre o resto das notas.



Intuitivamente, podemos tentar multiplicar (k & 7) por 12/7 (12 semitons e 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
      
      





Se olharmos para esses números sem as casas decimais, notaremos imediatamente que eles não são lineares, como esperávamos:



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





Mas não realmente ... O espaçamento de "meio-tom" deve estar entre B / C e E / F, não entre C / D. Vamos tentar outras proporções (os sublinhados indicam semitons):



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





É claro que os valores 1.8 e 1.5 não são adequados: o primeiro tem apenas um semitom e o segundo tem muitos. Os outros dois, 1,6 e 1,7, parecem se adequar a nós: 1,7 fornece a escala maior GA-BC-D-EF, e 1,6 fornece a escala maior AB-CD-EFG. Exatamente o que precisamos!



Agora precisamos mudar um pouco os valores para que C seja 0, D seja 2, E seja 4, F seja 5 e assim por diante. Devemos ser compensados ​​por 4 semitons, mas subtrair 4 tornará a nota A abaixo da nota C, então, em vez disso, adicionamos 8 e calculamos o módulo 12 se o valor estiver fora de uma oitava:



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





Também temos que levar em consideração o caractere "sustenido", que é capturado pelo grupo m [3] da expressão regular. Se presente, aumente o valor da nota em 1 semitom:



// 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, devemos usar a oitava correta. As oitavas já estão armazenadas como números no grupo de expressões regulares m [5]. De acordo com a teoria musical, cada oitava é 12 Seminots, então podemos multiplicar o número da oitava por 12 e adicionar o valor da 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
      
      





Fixação



O que acontece se alguém indicar o número de oitavas como 10 ou 1000? Isso pode levar a um ultrassom! Devemos permitir apenas o conjunto correto de valores para tais parâmetros. Limitar o número entre os outros dois é comumente chamado de "fixação". O JS moderno tem uma função especial Math.clamp (x, low, high) , que, entretanto, ainda não está disponível na maioria dos navegadores. A alternativa mais simples é usar:



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





Mas como estamos tentando manter nosso código o menor possível, podemos reinventar a roda e parar de usar funções matemáticas. Usamos o padrão x = 0 para fazer a fixação funcionar com valores indefinidos também:



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 e duração da nota



Esperamos que o BPM seja passado como um parâmetro para a função out play () . Só temos que validar:



bpm = clamp(bpm, 40, 400);
      
      





Agora, para calcular quanto tempo uma nota deve durar em segundos, podemos obter sua duração musical (inteira / meio / quarto / ...), que está armazenada no grupo regex m [1]. Usamos a seguinte 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);
      
      





Se combinarmos essas fórmulas em uma e limitarmos a duração da nota, obteremos:



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





Além disso, não se esqueça da capacidade de especificar notas com pontos, que aumentam a duração da nota atual em 50%. Temos um grupo m [2], cujo valor pode ser um ponto . ou indefinido . Usando o mesmo método que usamos anteriormente para o sinal sustenido, obtemos:



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





Agora podemos calcular o número e a duração de cada nota. É hora de usar a API WebAudio para tocar uma música.



WEBAUDIO



Precisamos apenas de 3 partes de toda a API WebAudio : contexto de áudio, um oscilador para processar a onda sonora e um nó de ganho para ligar / desligar o som. Vou usar um oscilador retangular para fazer a melodia soar como aquele telefone velho e horrível tocando:



// 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 si só ainda não criará música, mas como analisamos nossa melodia RTTTL, podemos dizer ao WebAudio que nota tocar, quando, com que frequência e por quanto tempo.



Todos os nós WebAudio têm um método setValueAtTime especial que agenda um evento de mudança de valor (frequência ou ganho de nó).



Se você se lembra, no início do artigo já tínhamos o código ASCII para a nota armazenado como k, o índice da nota como n e tínhamos a duração da nota em segundos. Agora, para cada nota, podemos fazer o seguinte:



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





É tudo. Nosso programa play () agora pode tocar melodias inteiras escritas em notação RTTTL. Aqui está o código completo, com pequenos esclarecimentos, como usar v como um atalho para setValueAtTime ou usar variáveis ​​de uma letra (C = contexto, z = oscilador porque produz um som semelhante, g = ganho, q = bpm, c = grampo):



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





Quando minimizado com terser, esse código tem apenas 417 bytes. Ainda está abaixo do limite de 512 bytes. Por que não adicionamos uma função stop () para interromper a reprodução:



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





Isso ainda está em torno de 445 bytes. Se você colar este código no console do desenvolvedor, poderá reproduzir o RTTTL e interromper a reprodução chamando as funções JS play () e stop () .



UI



Acho que adicionar um pouco de IU ao nosso sintetizador tornará o momento de fazer música ainda mais agradável. Neste ponto, eu sugeriria esquecer o código de golfe. É possível criar um pequeno editor para ringtones RTTTL sem salvar bytes usando HTML e CSS normais e incluindo um script reduzido apenas para reprodução.



Decidi não postar o código aqui porque é muito chato. Você pode encontrá-lo no github . Você também pode experimentar a versão demo aqui: https://zserge.com/nokia-composer/ .







Se a musa o deixou e você não tem vontade de escrever nenhuma música, experimente algumas músicas existentes e curta o som familiar:





A propósito, se você realmente compôs algo, compartilhe o url (todas as músicas e BPM são armazenados na parte hash do url, portanto, salvar / compartilhar suas músicas é tão fácil quanto copiar ou marcar o link.



Espero que tenha gostado. Veja este artigo Você pode acompanhar as novidades no Github , Twitter ou se inscrever via rss .



All Articles