[Перевод] Синтезатор мелодий 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

Recent Posts

Apple возобновила переговоры с OpenAI и Google для интеграции ИИ в iPhone

Apple возобновила переговоры с OpenAI о возможности внедрения ИИ-технологий в iOS 18, на основе данной операционной системы будут работать новые…

5 дней ago

Российская «дочка» Google подготовила 23 иска к крупнейшим игрокам рекламного рынка

Конкурсный управляющий российской «дочки» Google подготовил 23 иска к участникам рекламного рынка. Общая сумма исков составляет 16 млрд рублей –…

5 дней ago

Google завершил обновление основного алгоритма March 2024 Core Update

Google завершил обновление основного алгоритма March 2024 Core Update. Раскатка обновлений была завершена 19 апреля, но сообщил об этом поисковик…

5 дней ago

Нейросети будут писать тексты объявления за продавцов на Авито

У частных продавцов на Авито появилась возможность составлять текст объявлений с помощью нейросети. Новый функционал доступен в категории «Обувь, одежда,…

5 дней ago

Объявлены победители международной премии Workspace Digital Awards-2024

24 апреля 2024 года в Москве состоялась церемония вручения наград международного конкурса Workspace Digital Awards. В этом году участниками стали…

6 дней ago

Яндекс проведет гик-фестиваль Young Con

27 июня Яндекс проведет гик-фестиваль Young Con для студентов и молодых специалистов, которые интересуются технологиями и хотят работать в IT.…

6 дней ago