Как стать автором
Обновить

MIDI → Метр → MIDI

Уровень сложностиПростой
Время на прочтение11 мин
Количество просмотров2.3K

Статья поведёт нас через границу, где сходятся 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 мкс).

Простая конвертация

Что мы теперь знаем:

  1. длительность четвертной ноты в тиках (TPQN);

  2. длительность четвертной ноты в микросекундах (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 из тиков в метрическое в присутствие изменений темпа? Алгоритм прост:

  1. взять все отрезки с фиксированным темпом до T;

  2. для каждого из них вычислить метрическую длину с помощью ранее написанного метода MidiToMetric;

  3. сложить полученные значения.

Например, есть такой файл:

Здесь красными ромбами обозначены изменения темпа; 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 до 100250 мс/четверть. Учитывая, что 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 в тики:

  1. идём по изменениям темпа, считая при этом пройденное метрическое время M;

  2. если время больше или равно T, выходим из цикла;

  3. складываем 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 и щеголять знанием парочки композиторов и музыкальных терминов. В любом случае, надеюсь, было интересно.

Теги:
Хабы:
Всего голосов 18: ↑18 и ↓0+18
Комментарии1

Публикации

Истории

Работа

Ближайшие события

Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург