Представьте: вы создаёте новый шедевр в любимой DAW, вставляете в проект MIDI-файл, редактор показывает, что ноты в нём имеют восьмую длительность. Не обращая на это внимания, вы продолжаете творить. Но, постойте. А как DAW, собственно, понимает, что ноты в файле восьмые?
В статье попробуем разобраться, как времена в MIDI-файле соотносятся с главным форматом времени при работе с музыкой – тактами и долями. Результатом наших исследований будет законченный алгоритм на C#.
План повествования будет таким: определимся с терминологией, посмотрим на варианты решения задачи некоторыми DAW, а затем реализуем логику преобразования времени. Если хочется сразу увидеть финальный код, можно перейти к разделу Заключение.
Содержание
Необходимая теория
Для начала немного сведений о формате MIDI-файлов. Я кратко изложу только то, что нам потребуется далее в статье. Те же из вас, кто хочет скоротать вечер за чтением технических спецификаций, всегда могут обратиться к полному документу на сайте midi.org (для скачивания нужна регистрация).
MIDI-файл состоит из блоков, в каждом из которых в хронологическом порядке записана последовательность MIDI-событий (например, взятие ноты или изменение контроллера). Каждое из них снабжено временем относительно предыдущего события – delta-time. Дельты измеряются в тиках (ticks) и представляют собой неотрицательные целые числа.
Например, дельта 20 говорит о том, что событие отстоит от предыдущего во времени на 20. Но на 20 чего? Значение этих чисел задаётся структурой в заголовке MIDI-файла, которая, согласно спецификации, называется division (тип деления времени). Эта структура содержит информацию о формате времени в файле, и в 99.(9)% случаев в ней будет записано количество тиков на четвертную ноту (по правде говоря, второй формат – MIDI time code / SMPTE – мне ни разу не встречался). Этот параметр мы будем обозначать далее ticksPerQuarterNote
.
Так что с тактами и долями?
Имея на руках вышеуказанную теорию, конвертация времени и длины из тиков в такты (bars) и доли (beats) выглядит делом нехитрым. Размер (time signature) композиции определяется числителем numerator
и знаменателем denominator
. Например, в вальсовом размере 3/4
имеем numerator = 3
, а denominator = 4
. Длина тактовой доли 1/denominator
в тиках вычисляется из пропорции:
1/4 = ticksPerQuarterNote
1/denominator = beatLength
Таким образом, метод вычисления beatLength
будет таким:
private static int GetBeatLength(int denominator, int ticksPerQuarterNote) =>
4 * ticksPerQuarterNote / denominator;
Умножив на numerator
, получим длину такта:
private static int GetBarLength(int numerator, int denominator, int ticksPerQuarterNote) =>
numerator * GetBeatLength(denominator, ticksPerQuarterNote);
Зная длины такта и тактовой доли, перевести количество тиков ticks
в количество тактов и долей можно простейшим жадным алгоритмом:
разделив
ticks
на длину такта (barLength
), получим количество целых тактов;взяв остаток от предыдущего деления и разделив его на
beatLength
, получим количество целых долей;остаток от предыдущего деления равен оставшимся тикам.
На C# соответствующий метод будет выглядеть как-то так:
private static (long Bars, long Beats, long Ticks) ConvertToBarsBeatsTicks(
long ticks,
int numerator,
int denominator,
int ticksPerQuarterNote)
{
if (ticks == 0)
return (0, 0, 0);
var barLength = GetBarLength(numerator, denominator, ticksPerQuarterNote);
var bars = Math.DivRem(ticks, barLength, out ticks);
var beatLength = GetBeatLength(denominator, ticksPerQuarterNote);
var beats = Math.DivRem(ticks, beatLength, out ticks);
return (bars, beats, ticks);
}
Но прежде, чем мы проверим нашу логику, давайте взглянем, как разные DAW справляются с задачей. Я подготовил файл 1B_1b_1s.mid с одной нотой внутри:
Здесь голубыми линиями отмечены границы тактов, серыми длинными – границы долей, а короткими серыми – шестнадцатые длительности. В данном примере размер композиции 4/4
, а нота занимает 1 такт 1 долю и 1 шестнадцатую.
Эксперименты я выполнял в трёх DAW не самых последних версий: Steinberg Cubase, PreSonus Studio One и Cockos Reaper. Если у кого-то есть возможность и желание повторить описанные здесь действия в других программах, я был бы очень признателен.
Загрузив указанный выше файл в каждый из редакторов, получим такие варианты ответа на вопрос ”Какая длина ноты в тактах и долях?”:
Cubase:
1.1.1.0
Studio One:
1.1.1.0
Reaper:
1.1.25
Все испытуемые сошлись в правильном мнении. Сразу стоит пояснить, какие форматы использует каждая из DAW:
Cubase:
<такты>.<доли>.<шестнадцатые>.<тики>
Studio One:
<такты>.<доли>.<шестнадцатые>.<проценты шестнадцатой>
Reaper:
<такты>.<доли>.<проценты доли>
Так что ответ Reaper становится теперь понятным. Потому что шестнадцатая это как раз 25 процентов от доли (которая четвертная согласно размеру композиции 4/4
). Забавный факт: только Steinberg в своей документации явно описывает применяемый в своём редакторе формат времени. В документации к прочим DAW я не сумел найти такой информации, что грустно.
Чтобы закрыть тему отображения тактов и долей в различных музыкальных комбайнах, удостоверимся, что последний компонент времени в Studio One это именно проценты от шестнадцатой длительности. Загрузим в три редактора такой файл:
Нота здесь занимает 1 такт 1 долю 1 шестнадцатую и половину шестнадцатой. Соответствующий файл можно взять здесь: 1B_1b_1s_half.mid. На этот раз все программы дают разное число в последнем компоненте:
Cubase:
1.1.1.60
Studio One:
1.1.1.50
Reaper:
1.1.38
В тестируемых файлах ticksPerQuarterNote = 480
. Т.е. шестнадцатая длительность имеет длину 120
, а половина шестнадцатой (тридцать вторая) – 60
. Cubase показывает 60
, как есть, ибо тут тики. Studio One показывает 50
процентов от шестнадцатой. Reaper показывает 38
, потому что 1/16 + 1/32
в тиках это 120 + 60 = 180
, что есть 38
процентов от доли (180 / 480 ≈ 0.38
).
Теперь, наконец, проверим наш метод ConvertToBarsBeatsTicks
. Сперва возьмём файл 1B_1b_1s.mid. С помощью разрабатываемой мной библиотеки DryWetMIDI извлечём размер композиции, количество тиков на четвертную длительность и длину ноты в файле:
var midiFile = MidiFile.Read("1B_1b_1s.mid");
var tempoMap = midiFile.GetTempoMap();
var timeSignature = tempoMap.GetTimeSignatureAtTime((MidiTimeSpan)0);
var ticksPerQuarterNote = ((TicksPerQuarterNoteTimeDivision)tempoMap.TimeDivision).TicksPerQuarterNote;
var noteLength = midiFile.GetNotes().First().Length;
Вызвав метод ConvertToBarsBeatsTicks
var (bars, beats, ticks) = ConvertToBarsBeatsTicks(
noteLength,
timeSignature.Numerator,
timeSignature.Denominator,
ticksPerQuarterNote);
Console.WriteLine($"{bars}.{beats}.{ticks}");
получим такой вывод в консоли: 1.1.120
. Пока всё так, как и должно быть: 1 такт 1 доля и 1 шестнадцатая длительность (которая равна 120
тикам при ticksPerQuarterNote = 480
).
Для второго файла мы получим такой ответ: 1.1.180
. Т.е. 1 такт 1 доля 1 шестнадцатая и 1 тридцать вторая длительности. Последние два компонента в сумме дают 180
тиков. Можно с уверенностью сказать, что наш ультрасложный метод работает!
Но вряд ли мы собрались с вами посмотреть, как написать 5 строк кода, верно?
Всё сложнее
Беспощадная реальность такова, что в композиции размер может не быть константой. Он может меняться на каком-то из тактов, причём неоднократно. Возьмём вот такой файл (2B.mid):
Нота длиной в 2 такта, ничего ужасного. Но что, если мы добавим изменения размера в DAW:
Простой вопрос: нота по-прежнему имеет длину 2 такта? Ответы могут быть разными:
Cubase:
2.2.3.0
Studio One:
2.0.0.0
Reaper:
2.2.75
Studio One считает, что ничего не поменялось, нота всё так же занимает 2 такта. Cubase предлагает вариант 2 такта 2 доли и 3 шестнадцатые. Reaper с ним согласен с поправкой на свой формат времени (3 шестнадцатые это 75
процентов от доли, которая четвертная).
Как мы видим, перевод MIDI-времени в музыкальные термины не самая простая тема. Различные DAW используют разные форматы и по-разному учитывают изменения размера. Но кому верить?
По мне, правы Cubase и Reaper. Это именно то, что мы видим – 1 такт размера 4/4
+ 1 такт размера 5/16
+ 2 доли такта с размером 3/4
+ 3 шестнадцатые:
И поэтому я предлагаю реализовать алгоритм, который будет конвертировать тики в такты и доли аналогично Cubase. Этот алгоритм реализован в DryWetMIDI, и в этой статье я вспомню вместе с вами, как я к нему пришёл. Библиотека предоставляет также возможность конвертировать и в формат Reaper’а, но здесь мы не будем рассматривать эту логику, тем более, что по большей части она такая же.
И перед тем, как мы наконец начнём продумывать наш конвертер, рассмотрим ещё несколько файлов. Первый – Main.mid:
В отличие от предыдущих примеров, здесь изменения размера добавлены прямо в файл. Длина ноты по мнению Cubase 3.1.1.60
. И это может быть неочевидно на первый взгляд. Давайте разберёмся, почему получается именно такое значение.
Разметим такты, доли и шестнадцатые, которые занимает нота:
Во-первых, у нас есть 2 полных такта: 1 такт размера 5/16
и 1 такт размера 3/8
. Во-вторых, у нас есть 4 полных доли: 2 доли в такте размера 4/4
и 2 доли в такте размера 3/4
. Любопытный момент заключается в том, что из этих долей получается ещё 1 такт.
Дело в том, что Cubase при суммировании долей смотрит на размер, в котором начинается нота. А начинается она в такте размера 4/4
, т.е. в такте с 4 долями. Вот и выходит, что 4 доли дают нам ещё 1 полный такт.
Итого у нас уже 3 такта. Но откуда-то должна взяться ещё одна доля. 3 шестнадцатые слева и 2 справа дают нам в сумме 5 шестнадцатых. И снова Cubase вычленяет из них более крупную сущность, опираясь на размер в начале ноты. Таким образом, из 5 шестнадцатых 4 образуют долю (1/4
). У нас осталась 1 шестнадцатая и 1 тридцать вторая. И мы получаем 3 такта 1 долю 1 шестнадцатую и 60 тиков.
Если мы попросим DryWetMIDI посчитать длину ноты в исследуемом файле:
var tempoMap = midiFile.GetTempoMap();
var length = midiFile.GetNotes().First().LengthAs<BarBeatTicksTimeSpan>(tempoMap);
Console.WriteLine(length);
то получим в консоли 3.1.180
, что то же самое (180 = 120 + 60 = 1/16 + 60
).
Вывод о том, что Cubase опирается именно на размер такта, в котором начинается нота, может выглядеть надуманно. Что ж, рассмотрим ещё один пример – Main2.mid:
Здесь я сразу разметил такты и доли. Следуя логике, которую мы применили в прошлый раз, нетрудно догадаться о размере ноты по мнению Cubase – 3.0.0.0
. Есть 2 полных такта, а 5 долей (3 слева и 2 справа) образуют ещё один согласно размеру в начале ноты. DryWetMIDI выдаст такой же ответ.
Ещё один тестовый файл – Main3.mid:
Размер ноты 3.3.1.60
. С 3 тактами понятно. 3 доли не дотягивают до целого такта (нужно 5 долей). Остаётся 1 шестнадцатая и 1 тридцать вторая (60 тиков). Ответ DryWetMIDI совпадает – 3.3.180
.
И здесь мы, наконец, приступаем к разработке алгоритма.
Пишем код
Сразу скажу, что представленный алгоритм может оказаться неэффективным, а автор кода, его реализующего, может быть повинен в криворукости. И тем не менее, логика выглядит довольно простой, а код не столь ужасным.
Опишем сигнатуру метода конвертации из тиков в такты и доли, а также инициализируем пару переменных:
private static (long Bars, long Beats, long Ticks) ConvertToBarsBeatsTicks(
long timeSpan,
long time,
TempoMap tempoMap)
{
if (timeSpan == 0)
return (0, 0, 0);
var ticksPerQuarterNote = ((TicksPerQuarterNoteTimeDivision)tempoMap.TimeDivision).TicksPerQuarterNote;
var endTime = time + timeSpan;
}
где timeSpan
– преобразуемая длина в тиках; time
– время в тиках от начала файла, где начинается timeSpan
; tempoMap
– объект типа TempoMap, содержащий в себе и ticksPerQuarterNote
, и все изменения темпа и размера в файле. Проверки входных аргументов опущены для простоты кода.
Первым делом посчитаем количество полных тактов между изменениями размера, которые попадают на timeSpan
:
Взять изменения размера, пересекающие timeSpan
, не составляет труда:
var timeSignatureChanges = tempoMap
.GetTimeSignatureChanges()
.Where(change => change.Time >= time && change.Time <= endTime)
.ToArray();
Теперь же получим количество тактов от первого и до последнего изменения размера в timeSignatureChanges
:
var bars = 0L;
for (var i = 0; i < timeSignatureChanges.Length - 1; i++)
{
var timeSignature = timeSignatureChanges[i].Value;
var barLength = GetBarLength(timeSignature.Numerator, timeSignature.Denominator, ticksPerQuarterNote);
bars += (timeSignatureChanges[i + 1].Time - timeSignatureChanges[i].Time) / barLength;
}
Ничего сложного. Берём отрезки между изменениями размера, попавшими на timeSpan
, вычисляем для каждого отрезка количество тактов и суммируем эти значения. Для вычисления barLength
мы воспользовались ранее написанным нами методом GetBarLength
. Разумеется, если изменений размера не было (массив timeSignatureChanges
пуст), bars
будет равняться 0
.
Теперь мы обработаем части, выходящие за первое и последнее изменения размера:
Сперва определимся, что есть первое и последнее изменения:
var firstTime = timeSignatureChanges.FirstOrDefault()?.Time ?? time;
var lastTime = timeSignatureChanges.LastOrDefault()?.Time ?? time;
var firstTimeSignature = tempoMap.GetTimeSignatureAtTime((MidiTimeSpan)time);
var lastTimeSignature = tempoMap.GetTimeSignatureAtTime((MidiTimeSpan)lastTime);
Обратите внимание на ?.Time ?? time
. Дело в том, что изменений размера может и не быть, но нам нужно отталкиваться от чего-то при дальнейших вычислениях. Поэтому в случае пустого массива timeSignatureChanges
просто возьмём time
в качестве времени и первого и последнего “изменения” размера.
Теперь отдельно для правой и левой частей timeSpan
получим количество тактов, долей и тиков, используя наш прошлый метод ConvertToBarsBeatsTicks
для конкретного размера:
var (barsBefore, beatsBefore, ticksBefore) = ConvertToBarsBeatsTicks(
firstTime - time,
firstTimeSignature.Numerator,
firstTimeSignature.Denominator,
ticksPerQuarterNote);
var (barsAfter, beatsAfter, ticksAfter) = ConvertToBarsBeatsTicks(
endTime - lastTime,
lastTimeSignature.Numerator,
lastTimeSignature.Denominator,
ticksPerQuarterNote);
В случае отсутствия изменений размера, согласно коду выше, все компоненты будут рассчитаны в вызове ConvertToBarsBeatsTicks
для левой части, а значения barsBefore
, beatsBefore
и ticksBefore
окажутся равными 0
.
Сразу добавим такты слева и справа к переменной bars
, объявленной ранее:
bars += barsBefore + barsAfter;
и продолжим работать с оставшимися долями (beatsBefore
, beatsAfter
) и тиками (ticksBefore
, ticksAfter
):
Как мы выяснили ранее, доли по краям могут сложиться в дополнительный такт. Опишем эту логику в коде:
var beats = beatsBefore + beatsAfter;
if (beats > 0 && beatsBefore > 0 && beats >= firstTimeSignature.Numerator)
{
bars++;
beats -= firstTimeSignature.Numerator;
}
Думаю, условие в if
предельно понятное. А вот к телу, вероятно, есть вопрос. А именно: почему мы прибавляем только 1 такт (bars++
)? По идее стоит использовать конструкцию вроде этой:
bars += Math.DivRem(beats, firstTimeSignature.Numerator, out beats);
Возьмём вот такой файл – Main4.mid:
Здесь 3 доли слева и 12 справа. В сумме 15 должны дать 3 дополнительных такта согласно размеру 5/8
. Однако Cubase скажет, что длина ноты 3.10.0.0
. Т.е. он-таки добавил такт, но использовал только 2 доли справа, оставив 10 на счету такта с размером 13/8
:
Именно поэтому наш код ведёт себя так же, пробуя завершить только один такт с размером в time
.
Аналогично поступаем с тиками:
var ticks = ticksBefore + ticksAfter;
if (ticks > 0)
{
var beatLength = GetBeatLength(firstTimeSignature.Denominator, ticksPerQuarterNote);
if (ticksBefore > 0 && ticks >= beatLength)
{
beats++;
ticks -= beatLength;
}
}
Снова пробуем завершить одну долю с правым размером, остаток сохраняется в ticks
.
И на этом всё. Осталось только вернуть результат, и метод готов:
return (bars, beats, ticks);
Пришло время собрать наши тестовые файлы в кучу и проверить работу алгоритма:
var fileNames = new[]
{
"Main",
"Main2",
"Main3",
"Main4"
};
foreach (var fileName in fileNames)
{
var midiFile = MidiFile.Read($"{fileName}.mid");
var tempoMap = midiFile.GetTempoMap();
var note = midiFile.GetNotes().First();
var (bars, beats, ticks) = ConvertToBarsBeatsTicks(
note.Length,
note.Time,
tempoMap);
Console.WriteLine($"{fileName}: {bars}.{beats}.{ticks}");
}
Вывод консоли подтверждает правильность кода:
Main: 3.1.180
Main2: 3.0.0
Main3: 3.3.180
Main4: 3.10.0
Заключение
Вот такими ловкими движениями рук мы научились преобразовывать время из MIDI-формата в понятные музыкантам такты и доли. Разумеется, это лишь один из вариантов такого преобразования, и в статье мы наблюдали, как разные DAW используют свои подходы.
В заключение, как и обещал, привожу полный код разработанного нами алгоритма:
Разверните для просмотра кода
private static int GetBeatLength(int denominator, int ticksPerQuarterNote) =>
4 * ticksPerQuarterNote / denominator;
private static int GetBarLength(int numerator, int denominator, int ticksPerQuarterNote) =>
numerator * GetBeatLength(denominator, ticksPerQuarterNote);
private static (long Bars, long Beats, long Ticks) ConvertToBarsBeatsTicks(
long ticks,
int numerator,
int denominator,
int ticksPerQuarterNote)
{
if (ticks == 0)
return (0, 0, 0);
var barLength = GetBarLength(numerator, denominator, ticksPerQuarterNote);
var bars = Math.DivRem(ticks, barLength, out ticks);
var beatLength = GetBeatLength(denominator, ticksPerQuarterNote);
var beats = Math.DivRem(ticks, beatLength, out ticks);
return (bars, beats, ticks);
}
private static (long Bars, long Beats, long Ticks) ConvertToBarsBeatsTicks(
long timeSpan,
long time,
TempoMap tempoMap)
{
if (timeSpan == 0)
return (0, 0, 0);
var ticksPerQuarterNote = ((TicksPerQuarterNoteTimeDivision)tempoMap.TimeDivision).TicksPerQuarterNote;
var endTime = time + timeSpan;
var timeSignatureChanges = tempoMap
.GetTimeSignatureChanges()
.Where(change => change.Time >= time && change.Time <= endTime)
.ToArray();
var bars = 0L;
for (var i = 0; i < timeSignatureChanges.Length - 1; i++)
{
var timeSignature = timeSignatureChanges[i].Value;
var barLength = GetBarLength(timeSignature.Numerator, timeSignature.Denominator, ticksPerQuarterNote);
bars += (timeSignatureChanges[i + 1].Time - timeSignatureChanges[i].Time) / barLength;
}
var firstTime = timeSignatureChanges.FirstOrDefault()?.Time ?? time;
var lastTime = timeSignatureChanges.LastOrDefault()?.Time ?? time;
var firstTimeSignature = tempoMap.GetTimeSignatureAtTime((MidiTimeSpan)time);
var lastTimeSignature = tempoMap.GetTimeSignatureAtTime((MidiTimeSpan)lastTime);
var (barsBefore, beatsBefore, ticksBefore) = ConvertToBarsBeatsTicks(
firstTime - time,
firstTimeSignature.Numerator,
firstTimeSignature.Denominator,
ticksPerQuarterNote);
var (barsAfter, beatsAfter, ticksAfter) = ConvertToBarsBeatsTicks(
endTime - lastTime,
lastTimeSignature.Numerator,
lastTimeSignature.Denominator,
ticksPerQuarterNote);
bars += barsBefore + barsAfter;
var beats = beatsBefore + beatsAfter;
if (beats > 0 && beatsBefore > 0 && beats >= firstTimeSignature.Numerator)
{
bars++;
beats -= firstTimeSignature.Numerator;
}
var ticks = ticksBefore + ticksAfter;
if (ticks > 0)
{
var beatLength = GetBeatLength(firstTimeSignature.Denominator, ticksPerQuarterNote);
if (ticksBefore > 0 && ticks >= beatLength)
{
beats++;
ticks -= beatLength;
}
}
return (bars, beats, ticks);
}