[Перевод] Синтезатор мелодий Nokia Composer в 512 байтах

Немного ностальгии в нашем новом переводе — пробуем написать Nokia Composer и сочинить собственную мелодию.

Кто-то из читателей пользовался стареньким Nokia, например, моделями 3310 или 3210? Вы должны помнить его прекрасную функцию — возможность сочинять собственные рингтоны прямо на клавиатуре телефона. Расставляя ноты и паузы в нужном порядке, можно было воспроизвести популярную мелодию из динамика телефона и даже поделиться творением с друзьями! Если вы пропустили ту эпоху, вот как это выглядело:

Не впечатлило? Просто поверьте мне, тогда это казалось действительно крутым, особенно для тех, кто увлекался музыкой.

Музыкальная нотация (нотная запись) и формат, используемые в Nokia Composer, известны как RTTTL (Ring Tone Text Transfer Language). RTTL до сих пор широко используется любителями для воспроизведения монофонических мелодий на Arduino и др.

RTTTL позволяет писать музыку только для одного голоса, ноты можно играть только последовательно, без аккордов и полифонии. Однако это ограничение оказалось убойной фичей, поскольку такой формат легко писать и читать, легко анализировать и воспроизводить.

В этой статье мы попытаемся создать RTTTL-проигрыватель на JavaScript, добавив для интереса немного код-гольфинга и математических приемов, чтобы сделать код как можно короче.

Парсинг RTTTL

Для RTTTL применяется формальная грамматика. RTTL-формат — строка, ​​состоящая из трех частей: название мелодии, ее характеристики, такие как темп (BPM — beats per minute, то есть количество долей в минуту), октава и длительность ноты, а также сам код мелодии. Однако мы будем имитировать поведение самого Nokia Composer, распарсим только часть мелодии и рассмотрим темп BPM как отдельный входной параметр. Название мелодии и ее служебные характеристики оставлены за рамками этой статьи.

Мелодия — это просто последовательность нот / пауз, разделенная запятыми с дополнительными пробелами. Каждая нота состоит из длительности (2 / 4 / 8 / 16 / 32 / 64), высоты (c / d / e / f / g / a / b), опционально знака «диез» (#) и количества октав (от 1 до 3, так как поддерживаются только три октавы).

Самый простой способ — использовать регулярные выражения. Новые браузеры поставляются с очень удобной функцией matchAll, которая возвращает набор всех совпадений в строке:

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

Первое, что нужно выяснить о каждой ноте — как преобразовать ее в частоту звуковых волн. Конечно, мы можем создать HashMap для всех семи букв, обозначающих ноты. Но поскольку эти буквы расположены последовательно, их должно быть проще рассматривать как числа. Для каждой буквы-ноты мы находим соответствующий числовой код символа (код ASCII). Для «A» это будет 0x41, а для «a» — 0x61. Для «B / b» это будет 0x42 / 0x62, для «C / c» — 0x43 / 0x63 и так далее:

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

Нам, вероятно, стоит пропустить старшие биты, мы будем использовать только k&7 в качестве индекса ноты (a=1, c=2,…, g=7). А что дальше? Следующий этап не очень приятный, так как он связан с теорией музыки. Если у нас всего 7 нот, то мы считаем их как все 12. Это происходит потому, что диез / бемоль ноты неравномерно спрятаны между обычными нотами:

         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

Как можно заметить, индекс ноты в октаве увеличивается быстрее, чем код ноты (k&7). Кроме того, он увеличивается нелинейно: расстояние между E и F или между B и C составляет 1 полутон, а не 2, как между остальными нотами.

Интуитивно мы можем попробовать умножить (k&7) на 12/7 (12 полутонов и 7 нот):

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

Если мы посмотрим на эти числа без цифр после запятой, мы сразу заметим, что они нелинейны, как мы и ожидали:

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

Но не совсем… «Полутоновое» расстояние должно быть между B / C и E / F, а не между C / D. Попробуем другие коэффициенты (подчеркиванием указаны полутоны):

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

Понятно, что значения 1.8 и 1.5 не подходят: у первого только один полутон, а у второго — слишком много. Два других, 1.6 и 1.7, похоже, нам подходят: 1.7 дает мажорную гамму G-A-BC-D-EF, а 1.6 дает мажорную гамму A-B-CD-E-F-G. Как раз то, что нам нужно!

Теперь нам нужно немного изменить значения так, чтобы C было равно 0, D было 2, E было 4, F было 5 и так далее. Мы должны сместить на 4 полутона, но вычитание 4 сделает ноту A ниже ноты C, поэтому вместо этого мы добавляем 8 и вычисляем по модулю 12, если значение выходит за октаву:

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

Мы также должны принять во внимание знак «диез», который ловится группой m[3] регулярного выражения. Если он присутствует, следует увеличить значение ноты на 1 полутон:

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

Наконец, мы должны использовать правильную октаву. Октавы уже сохранены в виде чисел в группе регулярных выражений m[5]. Согласно теории музыки, каждая октава — это 12 семинот, поэтому мы можем умножить число октавы на 12 и добавить к значению ноты:

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

Clamping

Что будет, если кто-то укажет количество октав как 10 или 1000? Это может привести к ультразвуку! Нам следует разрешить только правильный набор значений для подобных параметров. Ограничение числа между двумя другими обычно называется «clamping». В современном JS есть специальная функция Math.clamp(x, low, high), которая, однако, пока недоступна в большинстве браузеров. Самая простая альтернатива — использовать:

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

Но поскольку мы стараемся максимально сократить наш код, можно заново изобрести колесо и отказаться от использования математических функций. Мы используем значение по умолчанию x=0, чтобы clamping работал и с undefined-значениями:

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

Темп и длительность ноты

Мы рассчитываем, что BPM будет передан в качестве параметра функции out play(). Нам остается только валидировать его:

bpm = clamp(bpm, 40, 400);

Теперь, чтобы вычислить, сколько нота должна длиться в секундах, мы можем получить ее музыкальную продолжительность (целая / половинная / четвертная /…), которая хранится в группе регулярного выражения m[1]. Используем следующую формулу:

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

Если мы объединим эти формулы в одну и ограничим продолжительность ноты, мы получим:

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

Также не стоит забывать и про возможность указания нот с точками, которые увеличивает длину текущей ноты на 50%. У нас есть группа m[2], значением которой может быть точка . или undefined. Применяя тот же метод, который мы использовали ранее для знака «диез», получаем:

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

Теперь мы можем рассчитывать номер и продолжительность для каждой ноты. Пора воспользоваться API WebAudio, чтобы сыграть мелодию.

WEBAUDIO

Нам нужны только 3 части из всего API WebAudio: аудиоконтекст, осциллятор для обработки звуковой волны и gain-нода для включения / выключения звука. Я буду использовать прямоугольный осциллятор, чтобы мелодия напоминала тот самый ужасный звонок старых телефонов:

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

Этот код сам по себе еще не создаст музыку, но, так как мы распарсили нашу RTTTL-мелодию, мы сможем указать WebAudio, какую ноту играть, когда, с какой частотой и как долго.

Все ноды WebAudio имеют специальный метод setValueAtTime, который планирует событие изменения значения (частота или усиление узла).

Если вы помните, ранее в статье у нас уже был код ASCII для ноты, сохраненный как k, индекс ноты как n, и у нас была duration (продолжительность) ноты в секундах. Теперь для каждой ноты мы можем сделать следующее:

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

Это всё. Наша программа play() теперь может воспроизводить целые мелодии, записанные в нотации RTTTL. Вот полный код с небольшими уточнениями, такими как использование v в качестве ярлыка для setValueAtTime или использование однобуквенных переменных (C=контекст, z=осциллятор, потому что он производит похожий звук, g=усиление, q=bpm, c=clamp):

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

При минификации с помощью terser этот код занимает всего 417 байт. Это все еще ниже поставленного порога в 512 байт. Почему бы нам не добавить функцию stop() для прерывания воспроизведения:

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

Получается все еще около 445 байт. Если вы вставите этот код в консоль разработчика, вы сможете воспроизвести RTTTL и остановить воспроизведение, вызвав JS функции play() и stop().

UI

Я думаю, добавление небольшого UI для нашего синтезатора сделает момент создания музыки еще более приятным. На этом этапе я бы предложил забыть о код-гольфинге. Можно создать крошечный редактор для RTTTL-мелодий без сохранения байтов, используя обычный HTML и CSS и включая минифицированный скрипт только для воспроизведения.

Я решил не размещать здесь код, так как это довольно скучно. Вы можете найти его на github. Также вы можете попробовать демо-версию здесь: https://zserge.com/nokia-composer/.

Если муза покинула вас и писать музыку совсем не хочется, попробуйте несколько существующих песен и насладитесь знакомым звуком:

Кстати, если вы действительно что-то сочинили, поделитесь URL-адресом (вся песня и BPM хранятся в хеш-части URL-адреса, поэтому сохранить / поделиться своими песнями так же просто, как скопировать или добавить ссылку в закладки.

Надеюсь, вам понравилась эта статья. Вы можете следить за новостями на Github, в Twitter или подписываться через rss.

Let’s block ads! (Why?)

Read More

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *