habrahabr

Записываем музыку при помощи CSS Grid

  • пятница, 10 мая 2024 г. в 00:00:14
https://habr.com/ru/companies/ruvds/articles/812621/
Слишком часто я наблюдал за тем, как импровизирующий музыкант трясущимися руками пытается увеличить pdf размером A4 на крошечном экране телефона в самом разгаре исполнения. Мы обязаны создать плавный и отзывчивый рендеринг музыки для веба!

В вебе нотная запись должна быть столь же доступной и плавной, как текст; однако пока это не так, и это уязвляет мои чувства. Давайте решим эту актуальную проблему.

Прототип Scribe


SVG, отрендеренный Scribe 0.2

Несколько лет назад я создал прототип рендерера музыки, который назвал 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 — смену шрифта, используемого для символов нотации. Пока мне удаётся стабильным образом отображать их расширенные наборы символов в разных браузерах.
  • Поддержку вложенных последовательностей, что позволит рендерить мелодии из нескольких партий.
  • Рендеринг разделённого стана — размещение нескольких партий на одном стане. Половина механики уже готова: нотация ударных и нотация пианино автоматически разделяются по высотам нот.
  • Рендеринг нескольких станов — размещение нескольких партий на нескольких выровненных станах.

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻