В вебе нотная запись должна быть столь же доступной и плавной, как текст; однако пока это не так, и это уязвляет мои чувства. Давайте решим эту актуальную проблему.
Прототип Scribe
Несколько лет назад я создал прототип рендерера музыки, который назвал Scribe. Он выполняет преобразование JSON в SVG. Изначально я стремился к созданию адаптивного рендерера музыки. Это было хорошее демо, но для дальнейшего развития пришлось бы писать сложный многопроходный движок генерации макетов, а у меня тогда возникли другие дела.
Вскоре после этого я занялся адаптированием Grid под проекты компании, и тут мне почудилось нечто знакомое: я задался вопросом, а не станет ли он решением некоторых проблем, с которыми я столкнулся при разработке Scribe?
Класс .stave
Нотный стан выстроен в подобие сетки. Высота ноты откладывается по вертикальной оси, а время идёт влево по горизонтальной оси. Я определю эти две оси в двух отдельных классах. Вертикальная ось, описывающая строки сетки, будет называться .stave
. К оси времени мы вернёмся чуть позже.
.stave
содержит строки сетки фиксированного размера, имеющие имена стандартных высот нот, и фоновое изображение, отрисовывающее стан. То есть для нотных линеек скрипичного ключа map строк может выглядеть так:
.stave {
display: grid;
row-gap: 0;
grid-template-rows:
[A5] 0.25em [G5] 0.25em [F5] 0.25em [E5] 0.25em
[D5] 0.25em [C5] 0.25em [B4] 0.25em [A4] 0.25em
[G4] 0.25em [F4] 0.25em [E4] 0.25em [D4] 0.25em
[C4] 0.25em ;
background-image: url('/path/to/stave.svg');
background-repeat: no-repeat;
background-size: 100% 2.25em;
background-position: 0 50%;
}
Если применить этот код к <div>
, то получим следующее:
Неплохо. Не особо информативно, но, изучив результат, мы увидим, что у каждой линейки и у каждого пустого поля теперь есть собственная строка сетки с названием высоты ноты, обозначающая каждую строку:
▍ Размещаем высоты нот на нотном стане
Каждая из строк стана может содержать одну из нескольких высот нот. Например, ноты G♭, G и G♯ должны находиться на одной линейке G.
Чтобы разместить описывающие эти ноты элементы DOM в нужные строки, я помещу названия нот в атрибуты data-pitch
и использую CSS, чтобы сопоставить значения data-pitch
со строками линеек.
.stave > [data-pitch^="G"][data-pitch$="4"] { grid-row-start: G4; }
Это правило обрабатывает ноты, начинающиеся с 'G'
и заканчивающиеся на '4'
, то есть оно присваивает ноты 'G♭4'
, 'G4'
и 'G♯4'
(а также дубль-бемоль 'G𝄫4'
и дубль-диез 'G𝄪4'
) строке G4
. Это необходимо проделать для каждой строки нотного стана:
.stave > [data-pitch^="A"][data-pitch$="5"] { grid-row-start: A5; }
.stave > [data-pitch^="G"][data-pitch$="5"] { grid-row-start: G5; }
.stave > [data-pitch^="F"][data-pitch$="5"] { grid-row-start: F5; }
.stave > [data-pitch^="E"][data-pitch$="5"] { grid-row-start: E5; }
.stave > [data-pitch^="D"][data-pitch$="5"] { grid-row-start: D5; }
...
.stave > [data-pitch^="D"][data-pitch$="4"] { grid-row-start: D4; }
.stave > [data-pitch^="C"][data-pitch$="4"] { grid-row-start: C4; }
И этого будет достаточно, чтобы начать размещать символы на нотном стане! У меня есть SVG-символы, которые я подготовил для прототипа Scribe. Давайте попробуем поместить парочку на стан:
<div class="stave">
<svg data-pitch="G4" class="head">
<use href="#head[2]"></use>
</svg>
<svg data-pitch="E5" class="head">
<use href="#head[2]"></use>
</svg>
</div>
Выглядит многообещающе. Теперь займёмся временем.
Класс .bar и его такты
С ритмом, пожалуй, работать сложнее. Не сразу понятно, какую выбрать наименьшую часть ритма, поддерживающую все возможные ритмы. Необходимо принять решение о том, какие минимальные длительности нот и какие кросс-ритмы нужно поддерживать в рамках сетки.
Если разделить такт на 24 столбца, то мы можем равномерно распределять восьмые (12 столбцов), шестнадцатые (6 столбцов), 32-е (3 столбца), а также значения триолей этих нот. Неплохо для начала.
Вот четырёхдольный такт, определённый как 4 × 24 = 96 столбцов сетки, плюс по столбцу в начале и в конце:
.bar {
column-gap: 0.03125em;
grid-template-columns:
[bar-begin]
max-content
repeat(96, minmax(max-content, auto))
max-content
[bar-end];
}
Добавим пару тактовых черт как контент ::before
и ::after
, а затем добавим символ ключа, отцентрированный на стане при помощи data-pitch="B4"
, и получим следующее:
<div class="stave bar">
<svg data-pitch="B4" class="treble-clef">
<use href="#treble-clef"></use>
</svg>
</div>
При внимательном изучении можно заметить, что ключ попал в первый столбец, и что есть 96 столбцов нулевой длины, по 24 на долю, каждый из которых разделён небольшим column-gap
:
▍ Размещение символов в долях
Теперь я воспользуюсь атрибутами data-beat
, чтобы присвоить доле элементы, а также применю правила CSS для сопоставления долей со столбцами сетки. После создания правила для каждой 1/24-й доли CSS map выглядит так:
.bar > [data-beat^="1"] { grid-column-start: 2; }
.bar > [data-beat^="1.04"] { grid-column-start: 3; }
.bar > [data-beat^="1.08"] { grid-column-start: 4; }
.bar > [data-beat^="1.12"] { grid-column-start: 5; }
.bar > [data-beat^="1.16"] { grid-column-start: 6; }
.bar > [data-beat^="1.20"] { grid-column-start: 7; }
.bar > [data-beat^="1.25"] { grid-column-start: 8; }
...
.bar > [data-beat^="4.95"] { grid-column-start: 97; }
Селектор атрибута ^=
делает правило устойчивым к ошибкам. Рано или поздно неокруглённые числа или числа с плавающей запятой неизбежно отрендерятся в data-beat
. Двух десятичных знаков после запятой достаточно для идентификации 1/24-й доли на столбец сетки.
Соединив это с классом stave
, мы сможем размещать символы в зависимости от их высоты и доли, присваивая data-beat
значение доли от 1
до 5
, а data-pitch
имя ноты. В процессе столбцы долей, содержащие эти символы, будут адаптироваться под них:
<div class="stave bar">
<svg class="clef" data-pitch="B4">…</svg>
<svg class="flat" data-beat="1" data-pitch="Bb4">…</svg>
<svg class="head" data-beat="1" data-pitch="Bb4">…</svg>
<svg class="head" data-beat="2" data-pitch="D4">…</svg>
<svg class="head" data-beat="3" data-pitch="G5">…</svg>
<svg class="rest" data-beat="4" data-pitch="B4">…</svg>
</div>
Отлично. Штили?
Готово. Флажки?
Готово. Разнесённость флажков можно улучшить (что, наверно, можно сделать при помощи margin), но с позиционированием всё нормально.
Плавная и адаптивная нотация
Если засунуть несколько таких тактов в контейнер flexbox, то мы получим адаптивную нотную запись:
<figure class="flex">
<div class="treble-stave stave bar">…</div>
<div class="treble-stave stave bar">…</div>
<div class="treble-stave stave bar">…</div>
…
</figure>
Очевидно, что здесь ещё многого не хватает, но основание заложено. Результат уже рендерится красивее, чем в других онлайн-рендерерах музыки.
Пространство между нотами
Пока не будем обращать внимания на вязки и отметим, что головки нот, которые ближе по времени друг к другу, рендерятся чуть ближе друг к другу:
Это сделано намеренно при помощи небольшого column-gap
. Сами столбцы имеют нулевую ширину, если в них нет головки ноты, но между событиями есть другие column-gap (по 24 на долю), которые в долях расположены дальше друг от друга, поэтому расстояние увеличивается.
Постоянство расстояний можно контролировать регулировкой margin символов. Чтобы расстановка была более постоянной, мы уменьшим column-gap
, увеличив margin головок нот:
Но это выглядит некрасиво, потому что интервалы между головками не дают читателю никакого представления о том, насколько быстр ритм. Однако в CSS есть удобный способ управления метриками. И теперь наша цель — настроить эти метрики, чтобы повысить читаемость.
▍ Ключи и обозначения размеров
Возможно, вы задаётесь вопросом, почему я использовал для горизонтальных и вертикальных интервалов отдельные классы, а не один? Разделив оси, мы можем заменить одну, не касаясь другой. Возьмём для примера такую мелодию:
Чтобы отобразить ту же мелодию в басовом ключе, можно заменить класс stave
классом bass-stave
, сопоставляющим те же атрибуты data-pitch
с басовым нотным станом:
<div class="bass-stave bar">...</div>
Или если сопоставить data-duration="5"
с 120 grid-template-columns
в .bar
, то тому же нотному стану можно присвоить размер 5/4:
<div class="bass-stave bar" data-duration="5">...</div>
Разумеется, я упростил объяснение. Не всё заканчивается сменой класса, необходимо также изменить расположение штилей и добавочных линеек.
Вот класс нотного стана, полностью меняющий сопоставление высот нот. В General MIDI голоса ударных инструментов находятся в группе нот в нижних октавах клавиатуры, но эти ноты не связаны с тем, где ударные печатаются на нотном стане. Можно определить в CSS класс drums-stave
, сопоставляющий эти ноты с нужными строками:
<div class="drums-stave bar" data-duration="4">...</div>
<div class="percussion-stave bar" data-duration="4">...</div>
Получилась очень читаемая нотация ударных, я очень ею доволен.
▍ Аккорды и текст
CSS Grid позволяет выравнивать в сетке нотации и другие символы. С временными событиями можно выравнивать, например, аккорды, тексты и динамику:
▍ Но что насчёт вязок?
Вязки, аккорды и длинные паузы преобразуются в столбцы со span сопоставлением их атрибутов data-duration
со значениями span grid-column-end
:
.stave > [data-duration="0.25"] { grid-column-end: span 6; }
.stave > [data-duration="0.5"] { grid-column-end: span 12; }
.stave > [data-duration="0.75"] { grid-column-end: span 18; }
.stave > [data-duration="1"] { grid-column-end: span 24; }
.stave > [data-duration="1.25"] { grid-column-end: span 30; }
...
▍ Размеры
Вся система имеет размер em
, так что для её масштабирования достаточно просто изменить font-size
:
▍ Ограничения Flex и Grid
Идеальна ли эта система? Честно говоря, я поражён тем, насколько хорошо она работает, но если уж искать недостатки, то…
1. CSS не может автоматически располагать новый символ ключа в начале каждой перенесённой строки
2. Он не может связать головку ноты с новой головкой в новой строке.
3. Вязки под углом — это совершенно отдельная история; вязки 1/16-х и 1/32-х нот сложно выровнять, потому что мы не знаем точно, где будут их штили, пока их не разместит Grid:
Так что для полного завершения работы потребуется немного JavaScript, но основную работу по размещению элементов здесь выполняет CSS, а значит, для JavaScript остаётся довольно мало труда.
<scribe-music>
Специальный элемент для рендеринга музыки
▍ Scribe
Репозиторий кода: github.com/stephband/scribe/
▍ JSON
Формат данных Scribe: github.com/soundio/music-json/
Я написал интерпретатор для этой новой системы CSS и обернул его в элемент <scribe-music>
. Он ещё далёк от готовности, но уже способен рендерить адаптивный нотный лист. Мне кажется, это интересный и полезный проект.
▍ Что он делает?
Элемент <scribe-music>
рендерит музыкальную нотацию из данных, найденных в её содержимом:
<scribe-music type="sequence">
0 chord D maj 4
0 F#5 0.2 4
0 A4 0.2 4
0 D4 0.2 4
</scribe-music>
Или из файла, полученного в атрибуте src
, например, из этого JSON:
<scribe-music
clef="drums"
type="application/json"
src="/static/blog/printing-music/data/caravan.json">
</scribe-music>
Или из объекта JS, указанного в свойстве .data
элемента.
Основная документация по всему этому есть в README.
▍ Попробовать самостоятельно
Можно протестировать текущую dev-сборку, импортировав в веб-страницу следующие файлы:
<link rel="stylesheet" href="https://stephen.band/scribe/scribe-music/module.css" />
<script type="module" src="https://stephen.band/scribe/scribe-music/module.js"></script>
Как я сказал, проект пока в разработке. В дальнейшем я хочу исследовать и попробовать реализовать следующие функции:
- Поддержку шрифтов SMuFL — смену шрифта, используемого для символов нотации. Пока мне удаётся стабильным образом отображать их расширенные наборы символов в разных браузерах.
- Поддержку вложенных последовательностей, что позволит рендерить мелодии из нескольких партий.
- Рендеринг разделённого стана — размещение нескольких партий на одном стане. Половина механики уже готова: нотация ударных и нотация пианино автоматически разделяются по высотам нот.
- Рендеринг нескольких станов — размещение нескольких партий на нескольких выровненных станах.
Автор: ru_vds