Pull to refresh

Конвертация MIDI-времени в такты и доли

Level of difficultyEasy
Reading time12 min
Views2.7K

Представьте: вы создаёте новый шедевр в любимой DAW, вставляете в проект MIDI-файл, редактор показывает, что ноты в нём имеют восьмую длительность. Не обращая на это внимания, вы продолжаете творить. Но, постойте. А как DAW, собственно, понимает, что ноты в файле восьмые?

В статье попробуем разобраться, как времена в MIDI-файле соотносятся с главным форматом времени при работе с музыкой – тактами и долями. Результатом наших исследований будет законченный алгоритм на C#.


План повествования будет таким: определимся с терминологией, посмотрим на варианты решения задачи некоторыми DAW, а затем реализуем логику преобразования времени. Если хочется сразу увидеть финальный код, можно перейти к разделу Заключение.

Содержание

  1. Необходимая теория

  2. Так что с тактами и долями?

  3. Всё сложнее

  4. Пишем код

  5. Заключение

Необходимая теория

Для начала немного сведений о формате 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 в количество тактов и долей можно простейшим жадным алгоритмом:

  1. разделив ticks на длину такта (barLength), получим количество целых тактов;

  2. взяв остаток от предыдущего деления и разделив его на beatLength, получим количество целых долей;

  3. остаток от предыдущего деления равен оставшимся тикам.

На 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);
}

Tags:
Hubs:
Total votes 22: ↑22 and ↓0+22
Comments0

Articles