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

ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/536284/


Комментарии

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

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