Реверс-инжиниринг бинарного формата на примере файлов Korg SNG. Часть 2
В прошлой статье я описал ход рассуждений при разборе неизвестного двоичного формата данных. Используя Hex-редактор Synalaze It!, я показал как можно разобрать заголовок двоичного файла и выделить основные блоки данных. Так как в случае формата SNG эти блоки образуют иерархическую структуру, мне удалось использовать рекурсию в грамматике для автоматического построения их древовидного представления в понятном человеку виде.
В этой статье я опишу похожий подход, который я использовал для разбора непосредственно музыкальных данных. С помощью встроенных возможностей Hex-редактора я создам прототип конвертера данных в распространенный и простой формат Midi. Нам придется столкнуться с рядом подводных камней и поломать голову над простой на первый взгляд задачей конвертации временных отсчетов. Наконец, я объясню как можно использовать полученные наработки и грамматику двоичного файла для генерации части кода будущего конвертера.
Разбор музыкальных данных
Итак, самое время разобраться с тем как хранятся музыкальные данные в файлах .SNG. Частично, я упоминал об этом в прошлой статье. В документации синтезатора указано, что SNG файл может содержать до 128 «песен», каждая из которых состоит из 16 треков и одного мастер трека (для записи глобальных событий и изменения мастер-эффектов). В отличие от формата Midi, где музыкальные события просто идут друг за другом с определенной дельтой времени, формат SNG содержит музыкальные такты.
Такт — это своего рода контейнер для последовательности нот. Размерность такта указывается в музыкальной нотации. Например, 4/4 — означает что такт содержит 4 доли, каждая из которых по длительности равна четвертной ноте. Упрощенно говоря, такой такт будет содержать 4 четвертных ноты или 2 половинных, или 8 восьмых.
Такты в файле SNG служат для редактирования треков во встроенном секвенсоре синтезатора. С помощью меню, можно удалять, добавлять и дублировать такты в любом месте трека. Также можно зацикливать такты или менять их размерность. Наконец, можно просто начать запись трека с любого такта.
Попробуем посмотреть, как все это хранится в двоичном файле. Общим контейнером для «песен» является блок SGS1. Данные каждой песни хранятся в блоках SDT1:
В блоках SPR1 и BMT1 хранятся общие настройки песни (темп, настройки метронома) и настройки отдельных треков (патчи, эффекты, параметры арпеджиатора и т.д.). Нас же интересует блок TRK1 — именно в нем содержатся музыкальные события. Но нужно спуститься еще на пару уровней иерархии — до блока MTK1
Вот, наконец, мы и нашли наши треки — это блоки MTE1. Попробуем записать на синтезаторе пустой трек небольшой длительности и еще один чуть подольше — чтобы понять как хранится информация о тактах в двоичном виде.
Похоже что такты хранятся как восьмибайтные структуры. Добавим пару нот:
Итак, мы можем предположить, что все события хранятся в таком же виде. Начало MTE блока содержит пока неизвестную информацию, затем до конца идет последовательность восьмибайтных структур. Откроем редактор грамматики и создадим структуру event с размером 8 байт.
Добавим структуру mte1Chunk, наследующую childChunk, и поместим ссылку на event в структуру data. Укажем, что event может повторяться неограниченное число раз. Далее, путем экспериментов, выясним размер и предназначение нескольких байт перед началом потока событий трека. У меня получилось следующее:
В начале блока MTE1 хранится количество событий трека, его номер и, предположительно, размерность события. После применения грамматики блок стал выглядеть так:
Перейдем к потоку событий. После анализа нескольких файлов с разными последовательностями нот, вырисовывается следующая картина:
# | Тип | Двоичное представление |
---|---|---|
1 | Такт1 | 01 00 00 … |
2 | Нота | 09 00 3C … |
3 | Нота | 09 00 3C … |
4 | Нота | 09 00 3C … |
5 | Такт2 | 01 C3 90 … |
6 | Нота | 09 00 3C … |
7 | Конец Трека | 03 88 70 … |
Похоже, что первый байт кодирует тип события. Добавим поле type в структуру event. Создадим еще две структуры, наследующие event: measure и note. Укажем соотвествующие Fixed Values для каждой из них. И, наконец, добавим ссылки на эти структуры в data блока mte1Chunk.
Применим изменения:
Что же, мы неплохо продвинулись. Осталось разобраться как кодируется высота и сила нажатия ноты, а также временной сдвиг каждого события относительно других. Попробуем снова сравнить наши файлы с результатом экспорта в midi, выполненным через меню синтезатора. В этот раз нас интересуют конкретно события нажатия нот.
Отлично! Похоже, что высота и сила нажатия ноты кодируются точно также как в midi формате всего парой байт. Добавим соотвествующие поля в грамматику.
С временным сдвигом к сожалению все не так просто.
Разбираемся с длительностью и дельтой
В формате midi события NoteOn и NoteOff — раздельные. Длительность ноты определяется дельтой времени между этими событиями. В случае формата SNG, где нет аналога события NoteOff, значения длительности и дельты времени должны храниться в одной структуре.
Чтобы разобраться как именно они хранятся, я записал на синтезаторе несколько последовательностей нот разной длительности.
Очевидно, что нужные нам данные находятся в 4х последних байтах структуры события. Невооруженным взглядом закономерности не видно, поэтому выделим интересующие нас байты в редакторе и воспользуемся инструментом Data Panel.
Судя по всему, и длительность ноты, и временной сдвиг кодируются парой байт (UInt16). При этом, порядок байт обратный — Little Endian. Сопоставив достаточное количество данных, я выяснил, что дельта времени здесь отсчитывается не от предыдущего события как в midi, а от начала такта. Если нота заканчивается в следующем такте, то в текущем ее длина будет 0×7fff, а в следующем она повторяется с такой же дельтой 0×7fff и длительностью отсчитываемой относительно начала нового такта. Соотвественно если нота звучит несколько тактов, то в каждом промежуточном у нее и длительность, и дельта будут равны 0×7fff.
Единицы времени дельта/длительность считаем в клетках. Нота 1 звучит нормально, а нота 2 продолжает звучать во 2-м и 3-м тактах.
На мой взгляд, выглядит все это несколько костыльно. С другой стороны, в музыкальной нотации ноты непрерывно звучащие несколько тактов обозначаются схожим образом с помощью легато.
В каких же «попугаях» у нас указана длительность? Как и в midi, тут используются «тики». Из документации известно, что длительность одной доли — 480 тиков. При темпе 100 ударов в минуту и размерности 4/4, длительность четвертной ноты составит (60/100) = 0.6 секунд. Соответственно длительность одного тика 0.6/480 = 0.00125 секунд. Стандартный такт 4/4 будет длится 4×480=1920 тиков или 2.4 секунды при темпе 100 уд/мин.
Все это нам пригодится в будущем. А пока добавим длительность и дельту к нашей структуре note. Также, отметим что в структуре такта имеется поле, хранящее количество событий. Еще одно поле содержит порядковый номер такта — добавим их в структуру measure.
Прототип конвертера
Теперь у нас достаточно информации чтобы попробовать сконвертировать данные. Hex-редактор Synalaze It в про версии позволяет писать скрипты на python или lua. При создании скрипта нужно определиться с чем мы хотим работать: с самой грамматикой, с отдельными файлами на диске или как-то обрабатывать распарсенные данные. К сожалению каждый из шаблонов имеет некоторые ограничения. Программа предоставляет ряд классов и методов для работы, но не все из них доступны из всех шаблонов. Возможно это недостаток документации, но я не нашел как можно загрузить грамматику для списка файлов, распарсить их и использовать полученные структуры для экспорта данных.
Поэтому мы создадим скрипт для работы с результатом разбора текущего файла. Этот шаблон реализует три метода: init, terminate и processResult. Последний вызывается автоматически и рекурсивно проходит по всем структурам и данным, полученным при парсинге.
Для записи сконвертированных данных в миди используем тулкит Python MIDI (https://github.com/vishnubob/python-midi). Так как мы реализуем Proof of Concept, то конвертацию длительностей нот и дельты проводить не будем. Вместо этого зададим фиксированные значения. Ноты с длительностью 0×7fff или с аналогичной дельтой пока просто отбросим.
Возможности встроенного редактора скриптов очень ограничены, поэтому весь код придется поместить в одном файле.
gist.github.com/bkotov/71d7dfafebfe775616c4bd17d6ddfe7b
Итак, попробуем сконвертировать файл и послушаем что у нас получилось
Хмм… А получилось довольно интересно. Первое, что пришло мне в голову когда я попытался сформулировать на что это похоже — бесструктурная музыка. Попробую дать определение:
Бесструктурная музыка — музыкальное произведение с редуцированной структурой, построенное на гармонии. Длительности нот и интервалы между нотами упразднены или сведены к одинаковым значениям.
Этакий гармоничный шум. Пусть будет перламутровым (по аналогии с белым, синим, красным, розовым и т.д.), вроде пока никто не занял это сочетание.
Пожалуй, надо попробовать обучить на моих данных нейросетку, возможно результат будет интересным.
Задачка для разминки ума
Это все замечательно, но основная задача все еще не решена. Нам нужно преобразовать длительности нот в события NoteOff, а временное смещение события относительно начала такта в дельту времени между соседними событиями. Попробую сформировать условия задачи более формально.
Имеется поток музыкальных событий следующего вида:
НачалоТакта1
Нота1
Нота2
Нота3
...
НотаN
НачалоТакта2
...
НачалоТактаN
Нота1
...
КонецТрека
Событие НачалоТакта можно описать структурой
типСобытия: 1
длительностьТакта: 1920
номерТакта: Int
числоСобытийВТакте: Int
Событие Нота описывается структурой
типСобытия: 9
высота: 0-127
силаНажатия: 0-127
длительность: 0-1920 или 0xFF
времяСНачалаТакта: 0-1920 или 0xFF
Если нота начинается в текущем такте, а заканчивается в следующем, то в текущем такте ее длительность будет равна 0xFF, а в следующем нота дублируется с времяСНачалаТакта=0xFF и с длительностью считаемой относительно начала нового такта. Если нота звучит дольше чем в двух тактах, то она дублируется в каждом промежуточном такте. В этом случае во всех промежуточных тактах у дублей времяСНачалаТакта = длительность = 0xFF.
Разные ноты могут звучать одновременно.
Необходимо за один проход преобразовать эти данные в поток событий midi. Эти события описываются структурами:
НотаВкл:
типСобытия: 9
высота: 0-127
силаНажатия: 0-127
времяСПрошлогоСобытия: Int
НотаВыкл:
типСобытия: 8
высота: 0-127
силаНажатия: 0-127
времяСПрошлогоСобытия: Int
Задача немного упрощена. В реальном SNG файле каждый такт может иметь разную размерность. Также кроме событий Note On/Off в потоке будут встречаться и другие события, например нажатие педали сустейна или изменение высоты тона с помощью pitchBend.
Свое решение этой задачи я приведу в следующей статье (если она будет).
Текущие итоги
Так как решение со скриптом не масштабируется на произвольное количество файлов, я решил написать консольный конвертер на языке Swift. Если бы я писал двусторонний конвертер, то созданные структуры грамматики пригодились бы мне в коде. Экспортировать их в структуры C или любого другого языка можно с помощью все той же функциональности скриптов встроенной в Synalize It! Файл с примером такого экспорта создается автоматически при выборе шаблона Grammar.
На текущий момент конвертер закончен на 99% (в том виде, который устраивает меня по функциональности). Код и грамматику я планирую выложить на github.
Пример, ради чего все затевалось, можно послушать здесь.
Как этот фрагмент звучит в готовом виде.