Статья поведёт нас через границу, где сходятся MIDI и метрическое время. В этом путешествии мы откроем брошюру по Международной системе единиц СИ, повстречаем файлы с более чем 6000 изменений темпа, столкнёмся с ошибками округления и напишем немного кода. Звучит заманчиво? Тогда добро пожаловать!
В прошлой статье Конвертация MIDI-времени в такты и доли у нас получилось перевести MIDI-время в привычные для музыки единицы измерения. Пришла пора взяться и за секунды, тем более, что рассматриваемые ниже преобразования куда проще.
Оглавление
Теория
Метрическое время
Все мы знаем шутку, приписываемую Леону Бамбрику:
В информатике есть две сложные проблемы: инвалидация кэша, придумывание названий и ошибка на единицу.
Но лично я не знал, что данная юмореска базируется на вполне серьёзном высказывании Фила Карлтона, в котором про ошибку на единицу ничего не сказано.
Говоря про вторую проблему, стоит заметить, что выбрать лаконичное и ёмкое название для какой-либо сущности действительно может быть делом крайне непростым. Не является исключением и структура, описывающая время в терминах секунд, минут и часов. К счастью, лучшие умы человечества не могли бросить нас в беде, а потому придумали Международную систему единиц СИ (от французского Système International, SI).
На сайте Международного бюро мер и весов можно найти брошюру с официальным описанием системы и скачать PDF-файл. В нашей стране СИ интегрирована в научную реальность в виде ГОСТ 8.417-2002, при этом PDF-файл с официальной страницы вы не скачаете, а будете листать документ постранично.
Система СИ есть подвид системы метрической, предоставляя нам метрическое время, базовой единицей измерения которого является секунда (см. раздел 2.3.1 брошюры). Разумеется, совершенно легитимными являются производные от неё: миллисекунды, наносекунды и иже с ними. Стандарт также не запрещает использовать минуты, часы и дни совместно с единицами СИ (см. раздел 4).
Время в MIDI
В одной из своих прошлых статей я уже приводил некоторые базовые сведения о том, что такое MIDI-время и с чем его едят. Дабы не быть уличённым в лени, а также самодостаточности статьи ради, здесь я также приведу необходимую нам теорию.
Говорить мы будем про MIDI-файлы. Сам протокол, конечно же, не только про файлы, но и про взаимодействие между музыкальными устройствами. И если с последними всё ясно в плане времени (мы просто засекаем промежутки между отправкой или приёмом событий), то в файлах таятся хитрости. Если вы почувствовали в себе приступ любопытства, рекомендую не сопротивляться и перейти на официальный сайт midi.org, где вы непременно найдёте все технические спецификации (напоминаю: для скачивания документов нужно быть зарегистрированным пользователем).
Дисклеймер: говорить будем про MIDI 1.0. “Вообще-то уже давно есть MIDI 2.0!” — воскликнете вы. Да, первый документ, описывающий новую долгожданную версию протокола, появился в феврале 2020-го, больше трёх лет назад на момент публикации данной статьи. Но во-первых, устройства, поддерживающие MIDI 2.0, можно пересчитать по пальцам, а во-вторых, устройства нас не интересуют. И да, в июне 2023-го года The MIDI Association обновила все спецификации касательно нового формата в свете явления миру SMF 2 (Standard MIDI File для MIDI 2.0), документа, официально зовущегося MIDI Clip File Specification. Как вы понимаете, он находится на первых месяцах жизни, а потому сейчас нет смысла бросаться в омут с головой.
Итак, что нам могут предложить плотно упакованные байты MIDI-файла стандарта прошлого века? Типичное строение файлов такое:
Файл состоит из блоков (chunks). Первым идёт блок заголовка (header chunk), после которого располагаются треки (track chunks). Последние как раз содержат музыкальные данные в виде MIDI событий (events) — взятие ноты, изменение контроллера, изменение темпа и многое другое.
В начале каждого события записано время относительно предыдущего — дельта-время (delta-time). Так что типичный трек выглядит как-то так:
Выходит, что абсолютное время T
события E
равно сумме всех дельт предыдущих событий и дельты E
. Вот такие времена T
и являются предметом нашего внимания, их мы будем конвертировать в метрическое представление и обратно.
Дельты и, соответственно, абсолютные времена измеряются в загадочных тиках (ticks). Помимо того, что это неотрицательные целые числа, можно сказать… А ничего больше сказать и нельзя. И тем не менее, нам нужно как-то соотносить тики с человеческими единицами измерения.
Согласно спецификации заголовок MIDI-файла хранит в себе слово (aka два байта) под названием division — деление времени, придающее тикам значение:
<Header Chunk> = <chunk type> <length> <format> <ntrks> <division>
The third word, <division>, specifies the meaning of the delta-times.
Процент файлов, в которых данное поле будет содержать что-то кроме количества тиков на четвертную ноту (ticks per quarter-note, TPQN
), бешено стремится к нулю, поэтому их мы рассматривать не будем. Но TPQN
сам по себе бесполезен, если не знать продолжительность четвертной ноты в понятных единицах. Этот недостающий кусочек пазла — событие изменения темпа (Set Tempo). Темп в MIDI-файлах задаётся количеством микросекунд (мкс) на четвертную ноту (microseconds per quarter-note, MPQN
):
Set Tempo, in microseconds per MIDI quarter-note
This event indicates a tempo change.
К слову, событий Set Tempo может и не быть вовсе, что наделяет файл темпом по умолчанию 120 BPM (beats per minute, ударов в минуту) или 500000
мкс на четвертную ноту (минута = 60000000
мкс → удар = 60000000 / 120
= 500000
мкс).
Простая конвертация
Что мы теперь знаем:
длительность четвертной ноты в тиках (
TPQN
);длительность четвертной ноты в микросекундах (
MPQN
).
Получается, теперь нет никакой тайны в длительности одного тика t
в микросекундах при заданном темпе:
t = MPQN / TPQN
Если абсолютное время T
это N
тиков, то в микросекундах оно рассчитывается элементарно:
T = N * t = N * MPQN / TPQN
Обратное преобразование M
микросекунд в тики также не составляет труда:
T = M * TPQN / MPQN
Превратим сии сложнейшие формулы в код на C# (для удобства представления метрического времени в коде будем использовать стандартную структуру TimeSpan):
private static TimeSpan MidiToMetric(long midiTime, long tpqn, long mpqn) =>
TimeSpan.FromMicroseconds(
(double)midiTime * mpqn / tpqn);
private static long MetricToMidi(TimeSpan metricTime, long tpqn, long mpqn) =>
(long)Math.Round(
metricTime.TotalMicroseconds * tpqn / mpqn);
Допустим, TPQN
= 100
тиков/четверть, MPQN
= 200
мкс/четверть, а конвертируемое время в тиках равно 100
. Выходит, у нас четвертная нота, а метод MidiToMetric
должен вернуть 200
мкс. Всё так, инструкция
Console.WriteLine(MidiToMetric(100, 100, 200));
выведет 00:00:00.0002000
, что есть ровно 200
мкс. Обратная конвертация
Console.WriteLine(MetricToMidi(TimeSpan.FromMicroseconds(200), 100, 200));
вернёт нас к исходному значению в 100
тиков.
Кстати, а вы любите фокусы? Покажу вам один:
const int tpqn = 96;
const int mpqn = 500_000;
var originalMetricTime = TimeSpan.FromMilliseconds(30);
var midiTime = MetricToMidi(originalMetricTime, tpqn, mpqn);
var metricTime = MidiToMetric(midiTime, tpqn, mpqn);
Console.WriteLine($"{originalMetricTime.Milliseconds} ms -> MIDI -> metric -> {metricTime.Milliseconds} ms");
Взмах волшебной палочки:
30 ms -> MIDI -> metric -> 31 ms
30
мс, пройдя конвертацию в MIDI, а затем снова в метрическое представление, стали 31
мс. Ловкость рук и никакого мошенничества.
Загвоздка вся, конечно же, в методе Math.Round. Все из нас слышали про ошибки округления, но не все сталкивались с ними. Если мы взглянем в отладчике, какое значение подвергается трансформации, то увидим 5.76
, которое округляется до 6
.
Если бы нас интересовали только внутренние расчёты, мы могли бы возвращать double
вместо long
в методе MetricToMidi
и убрать округление. Но так или иначе мы работаем с данными, которые затем будут записаны в MIDI-файл, а записать double
вы туда не сможете. Точнее, сможете, но файл будет сломан. В большинстве случаев он даже не сможет быть воспроизведён в плеерах. Поэтому совсем уйти от округления не удастся.
Напомню, что ошибки округления могут накапливаться:
var tempoMap = TempoMap.Create(
new TicksPerQuarterNoteTimeDivision(96),
Tempo.FromMillisecondsPerQuarterNote(500));
var midiFile = new PatternBuilder()
.SetNoteLength(new MetricTimeSpan(0, 0, 0, 30))
.Note("A4")
.Repeat(99)
.Build()
.ToFile(tempoMap);
var expectedDuration = TimeSpan.FromMilliseconds(30) * 100;
var actualDuration = midiFile.GetDuration<MetricTimeSpan>();
Console.WriteLine($"Expected duration: {expectedDuration}; actual duration: {actualDuration}");
Здесь с использованием библиотеки DryWetMIDI мы создаём MIDI-файл со 100 следующими друг за другом нотами длиной по 30
мс каждая. В итоге реальная длина файла получится больше ожидаемой на 125
мс:
Expected duration: 00:00:03; actual duration: 0:0:3:125
Для тысячи нот разница уже пойдёт на секунды:
Expected duration: 00:00:30; actual duration: 0:0:31:250
Меняем темп
Темп, конечно же, вещь непредсказуемая. Порой, даже для исполнителя произведения — благодаря рубато всегда можно сказать “Я так чувствую”. Вот и MIDI-файлы нередко содержат изменения скорости.
Если взять какую-нибудь большую базу MIDI-файлов и попробовать найти в ней экземпляр с наибольшим количеством изменений темпа, несложно будет столкнуться и с 1000 и с 5000 событий Set Tempo. Вот график, показывающий, как скачет темп с течением времени в одном из таких файлов:
Согласитесь, 6597 событий смены темпа выглядят дико, как и разница между минимальным и максимальным значениями. Не исключено, что буйство скорости внесено в файл искусственно, а в партитуре произведения такого нет. Хотя, судя по названию, в файле записана композиция Скарбо́ Мориса Равеля, которая относится к технически наиболее трудным произведениям мирового фортепианного репертуара, так что всё может быть. Как бы то ни было, алгоритмы работы со временем должны уметь справляться и с такими подопытными.
Ещё одним образцом классического произведения, в котором темп активно гуляет, является Венгерская рапсодия № 2 Франца Листа. На страницах нотной записи виднеются множество пометок вроде Andante Mesto или accel., а в самом начале вообще Lento a capriccio, что в общем и целом значит “темп по желанию”. Найденный MIDI-файл с этой композицией насчитывает скромные по сравнению с числом выше 48 событий Set Tempo:
Из MIDI в метр
Как же нам преобразовать время T
из тиков в метрическое в присутствие изменений темпа? Алгоритм прост:
взять все отрезки с фиксированным темпом до
T
;для каждого из них вычислить метрическую длину с помощью ранее написанного метода
MidiToMetric
;сложить полученные значения.
Например, есть такой файл:
Здесь красными ромбами обозначены изменения темпа; Tx — расстояния в тиках между событиями смены темпа; MPQNx — темп в микросекундах на четверть (MPQN1 указывает на темп по умолчанию в начале файла).
Таким образом, согласно инструкции выше, метрическое представление MIDI-времени T
:
Tmetric = MidiToMetric(T1, TPQN, MPQN1) + MidiToMetric(T2, TPQN, MPQN2) + MidiToMetric(T3, TPQN, MPQN3) + ... + MidiToMetric(TN-1, TPQN, MPQNN-1) + MidiToMetric(TN, TPQN, MPQNN)
Для программного воплощения данной идеи понадобится около 30 строк понятного кода, который при желании можно сократить (или увеличить, показав себя примерным программистом и добавив проверки аргументов):
private static TimeSpan MidiToMetric(long midiTime, TempoMap tempoMap)
{
var tpqn = ((TicksPerQuarterNoteTimeDivision)tempoMap.TimeDivision).TicksPerQuarterNote;
var tempoChanges = tempoMap
.GetTempoChanges()
.TakeWhile(t => t.Time < midiTime)
.ToArray();
var lastTempoChange = tempoChanges.LastOrDefault();
var result = MidiToMetric(
midiTime - (lastTempoChange?.Time ?? 0),
tpqn,
(lastTempoChange?.Value ?? Tempo.Default).MicrosecondsPerQuarterNote);
var lastTime = 0L;
var lastTempo = Tempo.Default;
foreach (var tempoChange in tempoChanges)
{
result += MidiToMetric(
tempoChange.Time - lastTime,
tpqn,
lastTempo.MicrosecondsPerQuarterNote);
lastTime = tempoChange.Time;
lastTempo = tempoChange.Value;
}
return result;
}
TempoMap — тип из библиотеки DryWetMIDI, из которого можно получить все изменения темпа, а также TPQN
. Я его использую здесь опять же простоты кода ради, вы можете передавать необходимые данные иными путями.
Перед циклом мы инициализируем результат метрическим временем, прошедшим с последнего изменения темпа и до переданного midiTime
, при этом заботясь о случае, когда события Set Tempo отсутствуют вовсе — вызываем MidiToMetric
от 0
с MPQN
равным дефолтным 500000
мкс/четверть. Цикл ниже суммирует метрические длительности предыдущих периодов фиксированного темпа.
Для начала проверим написанный метод для случая полного отсутствия изменений темпа (пример 1):
var tempoMap = TempoMap.Create(new TicksPerQuarterNoteTimeDivision(100));
Console.WriteLine(MidiToMetric(100, tempoMap));
Вывод программы:
00:00:00.5000000
Мы ожидаем получить время четвертной ноты. Что, собственно, и получаем в виде 500
мс (500000
мкс).
Далее попробуем указать единственное изменение темпа в самом начале (пример 2):
var tempoMap = TempoMap.Create(
new TicksPerQuarterNoteTimeDivision(100),
new Tempo(200));
Console.WriteLine(MidiToMetric(100, tempoMap));
Мы создаём TempoMap
с TPQN
= 100
и MPQN
= 200
. Вызывая MidiToMetric
для 100
тиков, получаем корректное значение:
00:00:00.0002000
А теперь создадим файл, в котором через 50
тиков от начала темп меняется на 250000
мкс/четверть, и сконвертируем 100
тиков в метрическое время (пример 3):
var midiFile = new MidiFile(
new TrackChunk(
new SetTempoEvent(250000) { DeltaTime = 50 }));
midiFile.TimeDivision = new TicksPerQuarterNoteTimeDivision(100);
var tempoMap = midiFile.GetTempoMap();
Console.WriteLine(MidiToMetric(100, tempoMap));
Что мы здесь ожидаем? Входное время 100
тиков, на 50
темп становится в 2 раза выше темпа по умолчанию (да, именно выше, ибо четвертная нота теперь длится меньше). Таким образом, от 0
до 50
тиков темп 500
мс/четверть, а с 50
до 100
— 250
мс/четверть. Учитывая, что TPQN
= 100
, первая половина длится 250
мс (половину от 500
мс), а вторая — 125
мс (половину от 250
). Итого:
00:00:00.3750000
Любопытства ради посчитаем метрическую длину файла Скарбо́:
var midiFile = MidiFile.Read("rav_scarbo.mid");
var tempoMap = midiFile.GetTempoMap();
Console.WriteLine(MidiToMetric(midiFile.GetTimedEvents().Last().Time, tempoMap));
По мнению аудиоплееров (Windows Media Player, jetAudio и т.д.) длина файла около 8 минут 30 секунд. Наша программа присоединяется к ним:
00:08:29.2009819
Из метра в MIDI
Приятно уметь преобразовывать тики в микросекунды. Но ещё лучше обзавестись алгоритмом и обратного преобразования. Чтобы перевести время в микросекундах T
в тики:
идём по изменениям темпа, считая при этом пройденное метрическое время
M
;если время больше или равно
T
, выходим из цикла;складываем MIDI-время последнего проверенного изменения темпа с результатом вызова
MetricToMidi
отT - M
.
С кодом будет понятнее:
private static long MetricToMidi(TimeSpan metricTime, TempoMap tempoMap)
{
var tpqn = ((TicksPerQuarterNoteTimeDivision)tempoMap.TimeDivision).TicksPerQuarterNote;
var lastTime = 0L;
var lastTempo = Tempo.Default;
var accumulatedTime = TimeSpan.Zero;
foreach (var tempoChange in tempoMap.GetTempoChanges())
{
accumulatedTime += MidiToMetric(
tempoChange.Time - lastTime,
tpqn,
lastTempo.MicrosecondsPerQuarterNote);
if (accumulatedTime > metricTime)
break;
lastTime = tempoChange.Time;
lastTempo = tempoChange.Value;
}
return lastTime + MetricToMidi(metricTime - accumulatedTime, tpqn, lastTempo.MicrosecondsPerQuarterNote);
}
Проверим метод на примерах 1, 2 и 3, показанных выше. Вызов MetricToMidi
должен привести нас в значения, переданные в MidiToMetric
в тех примерах, а именно в 100
тиков:
private static void MetricToMidi_NoTempoChanges()
{
var tempoMap = TempoMap.Create(new TicksPerQuarterNoteTimeDivision(100));
Console.WriteLine(MetricToMidi(
TimeSpan.FromMicroseconds(Tempo.Default.MicrosecondsPerQuarterNote),
tempoMap));
}
private static void MetricToMidi_SingleTempo()
{
var tempoMap = TempoMap.Create(
new TicksPerQuarterNoteTimeDivision(100),
new Tempo(200));
Console.WriteLine(MetricToMidi(
TimeSpan.FromMicroseconds(200),
tempoMap));
}
private static void MetricToMidi_SingleTempoChange()
{
var midiFile = new MidiFile(new TrackChunk(new SetTempoEvent(250000) { DeltaTime = 50 }));
midiFile.TimeDivision = new TicksPerQuarterNoteTimeDivision(100);
var tempoMap = midiFile.GetTempoMap();
Console.WriteLine(MetricToMidi(
TimeSpan.FromMicroseconds(375000),
tempoMap));
}
Запускаем:
MetricToMidi_NoTempoChanges();
MetricToMidi_SingleTempo();
MetricToMidi_SingleTempoChange();
Получаем:
100
100
100
Ну и напоследок, почему бы не проверить, что круговая конвертация (из MIDI в метр и затем в MIDI) длины файла Скарбо́ возвращает нас в корректное значение?
private static void MetricToMidi_RavScarbo()
{
var midiFile = MidiFile.Read("rav_scarbo.mid");
var tempoMap = midiFile.GetTempoMap();
var lastEventTime = midiFile.GetTimedEvents().Last().Time;
var midiTime = MetricToMidi(MidiToMetric(lastEventTime, tempoMap), tempoMap);
Console.WriteLine(midiTime == lastEventTime);
}
Вызов метода MetricToMidi_RavScarbo
напечатает true
. А значит, мы молодцы.
Заключение
Здесь наша внезапная прогулка подходит к концу. Программные инструкции в редакторе кода подобно нотам на нотном стане запечатали в себе музыку красивого алгоритма. Теперь мы можем давать умные ответы на Stack Overflow и щеголять знанием парочки композиторов и музыкальных терминов. В любом случае, надеюсь, было интересно.
MrButek
Спасибо, полезная статья. :)