Немного ностальгии в нашем новом переводе пробуем написать 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..0x67let 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.0floor((k&7)*12/7): 1 3 5 6 8 10 12 -------
Но не совсем Полутоновое расстояние должно быть между B / C и E / F, а не между C / D. Попробуем другие коэффициенты (подчеркиванием указаны полутоны):
note: a b c d e f gfloor((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) // => 1clamp(2, 1, 3) // => 2clamp(8, 1, 3) // => 3clamp(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 notesduration = 240 / bpm / clamp(m[1] || 4, 1, 64) * (1+!!m[2]/2);
Теперь мы можем рассчитывать номер и продолжительность для каждой ноты. Пора воспользоваться API WebAudio, чтобы сыграть мелодию.
WEBAUDIO
Нам нужны только 3 части из всего API WebAudio: аудиоконтекст, осциллятор для обработки звуковой волны и gain-нода для включения / выключения звука. Я буду использовать прямоугольный осциллятор, чтобы мелодия напоминала тот самый ужасный звонок старых телефонов:
// Osc -> Gain -> AudioContextlet 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 secondsfor (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 zerostop = _ => 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/.
Если муза покинула вас и писать музыку совсем не хочется, попробуйте несколько существующих песен и насладитесь знакомым звуком:
- рингтон Nokia
- рингтон iPhone, если вам больше нравится современная музыка
- Light My Fire
- Lose Yourself
- The Good, The Bad, and The Ugly
- Rondo Alla Turca (Mozart)
Кстати, если вы действительно что-то сочинили, поделитесь URL-адресом (вся песня и BPM хранятся в хеш-части URL-адреса, поэтому сохранить / поделиться своими песнями так же просто, как скопировать или добавить ссылку в закладки.
Надеюсь, вам понравилась эта статья. Вы можете следить за новостями на Github, в Twitter или подписываться через rss.