Немного ностальгии в нашем новом переводе — пробуем написать 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/.
Если муза покинула вас и писать музыку совсем не хочется, попробуйте несколько существующих песен и насладитесь знакомым звуком:
- рингтон Nokia
- рингтон iPhone, если вам больше нравится современная музыка
- Light My Fire
- Lose Yourself
- The Good, The Bad, and The Ugly
- Rondo Alla Turca (Mozart)
Кстати, если вы действительно что-то сочинили, поделитесь URL-адресом (вся песня и BPM хранятся в хеш-части URL-адреса, поэтому сохранить / поделиться своими песнями так же просто, как скопировать или добавить ссылку в закладки.
Надеюсь, вам понравилась эта статья. Вы можете следить за новостями на Github, в Twitter или подписываться через rss.