В этой статье я пишу о способе автоматизации транспонирования музыкальных нот. Вы можете подумать, что это скромная задача. В конце концов, транспозиция в основном эквивалентна добавлению или вычитанию высоты звука на интервал. Но написать код, который согласовывает синтаксис основ теории музыки, не так просто. Это связано с внутренней асимметрией западных музыкальных гамм и чем-то, что называется энгармонизмом - разным написанием классов высоты тона, которые представляют одно и то же значение в целочисленной нотации теории множеств. .

Учебник по теории музыки
В музыке есть так называемые высоты звука. Это вещи, представленные в письменной форме такими символами, как C♯6, A♭4 и F3. Первый символ любой высоты звука всегда является одной из семи возможных букв высоты звука в западной музыке в диапазоне от A до G включительно. Эти буквы могут иметь случайные наклонения, например, плоские () или острые (). Когда явно не присутствует никакой случайности, подразумевается естественная () случайность. Буква основного тона в контексте теории музыки всегда состоит из одного или нескольких таких случайностей. Например, B♭♭ является допустимым выражением, читаемым вслух как «си дабл-бемоль». ²

Комбинация буквы основного тона и случайных символов определяет класс высоты тона (далее PC). В посттональной теории компьютер может отображать целочисленное значение - полная гамма значений от 0 до 11 включительно. Значение 0 обычно отображается на ПК C, 1 C#, 2 D и так далее. Однако, как мы увидим позже, энгармонизм нарушает согласованность этих однозначных сопоставлений пар ключ-значение.

Наконец, целочисленный символ в конце символа высоты тона - это октава, с которой связан ПК. Полное 88-клавишное фортепиано имеет диапазон примерно семь с половиной октав, начиная с нуля (0). Комбинация ПК и октавы определяет высоту звука. Октава n колеблется от высоты Cn до Bn; следующий шаг после Bn, следовательно, будет Cn+1. Высота звука может быть отображена на определенную частоту, и каждая последующая октава удваивает частоту высоты звука. Например, A3 - это 220 Гц (Гц), A4 - 440 Гц, A5 - 880 Гц и так далее. Это также можно выразить как Pn = P0 * 2^n, где P - ПК, а n - октава. В code³ каждый компьютер можно представить себе как абстракцию массива частот основного тона. Например, вот генерация всех восьми B♭, найденных на пианино, с использованием ранее упомянутой формулы, с последующим поиском по индексу с частотой B♭2с:

val bFlat0 = 29.131
val bFlats = (0..8).map { bFlat0 * 2f.pow(it) }
println(bFlats[2])  // 116.579

Вы можете ожидать, что A0, следовательно, будет самой низкой возможной высотой тона, но на самом деле он имеет ничем не примечательную частоту 27.5 Гц. Причина, по которой она обозначена как нулевая октава, идиоматична для диапазона, поддерживаемого фортепиано.

Простая задача
Цель этой статьи - написать программу, которая надежно преобразует высоту звука в синтаксически правильный музыкальный результат. Например, транспонирование D4 на интервал большой секунды (M2) дает E4. Все просто, правда? Давайте транспонируем этот вывод вверх на другой M2. Это дает F♯4. Вдруг все не так просто - откуда это и почему? Вот еще одно забавное осложнение: высота C♭4 обозначается как имеющая более высокую октаву, чем B♯3, но последняя на самом деле имеет более высокую частоту, чем первая!

Транспонировать высоту звука непросто отчасти из-за асимметрии наиболее распространенных музыкальных сборников в западной музыке, мажорной и минорной гамм. Шкала C major состоит из следующего набора компьютеров: C D E F G A B. Его кажущаяся сплоченность подрывается, когда коллекция отображается на целочисленную нотацию: 0 2 4 5 7 9 11 - интервалы между каждым целым числом (включая последнее и обратное к первому) равны 2, 2, 1, 2, 2, 2, 1. Эти интервалы показывают присущая коллекции асимметрия. Итак, как мы можем согласовать это в коде? Давайте начнем с базового набора простых, удобочитаемых классов, чтобы охватить информацию, которую мы уже собрали.

enum class PitchLetter(val integerValue: Int) {
    A(9), B(11), C(0), D(2), E(4), F(5), G(7)
}
inline class Accidental(val modifier: Int)
data class PitchClass(
    val pitchLetter: PitchLetter,
    val accidental: Accidental
)
inline class Octave(val value: UInt)
data class Pitch(
    val pitchClass: PitchClass,
    val octave: Octave
)

В идеале результатом этой статьи будет что-то, что обеспечит простую поверхность API для транспонирования, например:

Pitch#transpose(Interval)

Как мог бы выглядеть предлагаемый Interval класс? Наивная первая попытка могла быть такой:

// Naive attempt
data class Interval(
    val distance: Int
)

Перестановка ПК, представленного в целочисленной системе счисления, действительно может быть сведена к единственному числу, которое представляет интервал. Однако ПК как комбинация питча и случайности требует дополнительной информации. Например, интервалы от C до D♯, E♭ и F♭♭ представляют собой целочисленное расстояние 3. Если бы код вызвал cNatural4.transpose(Interval(3)), как бы он узнал, на какую букву тона приземлиться? Теоретически существует бесконечное количество вариантов написания ПК, которые соответствуют одному и тому же целочисленному значению - это то, что подразумевается под термином энгармонизм. Цель концепции - тональная функция; например, аккорд, написанный C-E-G-B♭, интерпретируется иначе, чем аккорд, пишущийся энгармонически как C-E-G-A♯. Первый имеет тенденцию разрешаться к мажорному аккорду F, а второй - к мажорному аккорду B. Вот почему классу Interval на самом деле нужны два поля для предоставления всех данных, необходимых для однозначной транспозиции: одно для определения расстояния между буквами и одно целочисленное расстояние. Это полностью соответствует тому, как традиционно выражаются музыкальные интервалы - второстепенная 2-я, основная 3-я, совершенная 4-я и так далее, каждая из которых подразумевает соответствующее расстояние между буквами.

// Better
data class Interval(
    val letterDistance: Int,
    val integerDistance: Int
)
cNatural4.transpose(Interval(1, 3))  // D4
cNatural4.transpose(Interval(2, 3))  // E4
cNatural4.transpose(Interval(3, 3))  // F♭♭4

Чтобы достичь цели транспонирования Pitch, большинству его составных полей необходимо сначала понять, что означает транспонирование. То есть Pitch не может транспонировать без PitchClass знания того, как транспонировать, а это невозможно без PitchLetter знания того, как транспонировать.

Из предыдущего примера кода мы увидели, что Interval.letterDistance теперь различает предполагаемую выводимую букву основного тона. Транспонирование в контексте нашего класса PitchLetter enum - это просто добавление или вычитание этого значения в пределах размера юниверса от общего числа PitchLetter или 7. Код ниже формулирует это поведение:

enum class PitchLetter(val integerValue: Int) {
    A(9), B(11), C(0), D(2), E(4), F(5), G(7);

    fun transpose(interval: Interval): PitchLetter =
    with(values()) {
        val modLetterDistance = interval.letterDistance.modulo(size)
        val key = (ordinal + modLetterDistance).modulo(size)
        return get(key)
    }
}

Давайте разберемся с некоторыми деталями, показанными выше. Я добавил удобную функцию расширения Int.modulo(Int), чтобы согласовать циклическое качество букв высоты тона - если на пианино вы играете G ноту, следующая белая клавиша не будет H, она вернется к A. Вычисление по модулю для этого также возможно с операторами % и .rem(Int), но они могут дать отрицательные значения, которые приведут к сбою программы при вызове get(Int) индексирования. Эта настраиваемая Int.module(Int) функция всегда дает положительное возвращаемое значение между 0 и заданным аргументом размера юниверса, исключая.

Вызов этой функции транспонирования и получение ее результата теперь выглядит так:

val perfect5th = Interval(4, 7)
val result = PitchLetter.C.transpose(perfect5th)  // PitchLetter.G

Обработка PitchLetter перестановки не вызвала большого трения. Однако определение правильной случайности (ей) после транспозиции PitchClass и правой октавы после транспозиции Pitch немного сложнее.

PitchLetters имеют базовое целочисленное значение, на которое они отображаются, например C -> 0 и F -> 5. PitchClass состоит из PitchLetter и Accidental, которые являются просто встроенной оболочкой для Int. Поле accidental наклоняет целочисленное значение поля pitchLetter в отрицательном или положительном направлении. То есть, значение accidental.modifier -1 подразумевает одну плоскую, тогда как значение 2, например, подразумевает двойной диез. Сумма значений двух полей дает одно истинное целочисленное значение для PitchClass.

Затем вычисление транспозиции ПК сводится к применению любых случайностей к выходу вызова PitchLetter#transpose(Interval). Давайте посмотрим на нетривиальный пример: использование Interval(-1, -2) для преобразования компьютера C в B♭. Сначала мы вызовем transpose(interval) в поле PitchClass.pitchLetter, которое даст PitchLetter.B - PitchLetter с целым значением 11. Переход от ПК C к B близок к тому, что мы хотим, но не совсем в том, что это было случайно применено к B. Значение PitchClass.accidental.modifier, необходимое для получения B♭ ПК, тогда будет -1. Но как мы можем определить это значение модификатора? Целочисленное значение ПК Bb равно 10, поэтому мы берем недавно вычисленное PitchLetter.B целочисленное значение 11 и определяем, какое число необходимо, чтобы превратить это 11 в 10 - это число будет транспонированным значениемAccidental.modifier ПК. Однако это сбивает с толку из-за цикличности целочисленных значений ПК в пространстве ПК mod12. Переход от 11 к 10 фактически может быть достигнут в двух разных направлениях: результатом либо 11 — 1, либо 11 + 11 (против часовой стрелки и по часовой стрелке, соответственно, на рисунке ниже).

Эти два ответа будут иметь совершенно разные эффекты на значение Accidental.modifier, примененное к PitchLetter.B; один будет рассматриваться как цельный, а другой - как одиннадцать диезов. Последнее кажется абсурдным, но компьютер этого не знает - ему должны быть даны соответствующие инструкции, чтобы сделать правильный выбор из этих двух возможностей, и я никогда не указывал, будет ли транспозиция из C вниз или вверх до B♭. Таким образом, следующее уравнение, которое нормально работает в нециклическом пространстве, неоднозначно внутри одного:

10 = 11 + n  // Is n == -1? Or 11?

Выход из затруднительного положения в приведенном выше примере - зависеть от направления поля Interval.letterDistance. Interval(-1, -2) применительно к ПК C означает, что ПК убывает на мажорную секунду, потому что это поле отрицательное. Обладая этой важной информацией, мы можем сказать нашей программе, что -1 на самом деле является правильным случайным модификатором для использования в этом контексте, но не строго потому, что это направление дает accidental.modifier отрицательное значение. Давайте посмотрим на другой пример, чтобы понять, что я имею в виду. Interval(-1, 0) применяется к C доходности B♯. Это, безусловно, своеобразный интервал, который, вероятно, не часто встречается в практической музыке. После выполнения первой части транспонирования ПК для получения правильного PitchLetter, B необходимо определить, какое случайное событие применить к нему. Поле Interval.letterDistance по убыванию, но результат, который мы ищем, на самом деле на один шаг выше, чем B - B♯. Однако логика по-прежнему зависит от знания поля Interval.letterDistance по убыванию или по возрастанию. Чтобы не перегружать эту статью слишком большим количеством кода (весь его можно найти на моем GitHub⁶), я просто обрисую алгоритм.

  1. Получите направленное расстояние от ПК для переноса на новый PitchLetter.
  2. Вычтите Interval.integerDistance на это число, чтобы получить оставшееся расстояние до желаемого целочисленного значения ПК. Это будет значение Accidental.modifier нужного нам результата PitchClass.
  3. Создайте экземпляр нового PitchClass, используя значения транспонированного PitchLetter и значение, полученное на предыдущем шаге.

В примере с C по B♯ через Interval(-1, 0) эти шаги выглядят так:

  1. C до B - -1.
  2. Interval.integerDistance равно 0, поэтому вычитание его на расстояние букв направления -1 равно 0 — (-1), что дает 1!
  3. Предыдущий шаг дал нам наш правильно определенный случайный модификатор - резкий, поскольку значение было положительным.

Теперь, когда метод транспонирования ПК установлен, наш последний шаг - транспонировать Pitch. Поскольку работа по транспонированию Pitch.pitchClass уже была сделана ранее, все, что осталось, - это прикрепить к ней правую октаву. Мы рассчитаем это, используя полеInterval.integerDistance. Однако способ описания ПК делает эту задачу интересной. Ранее в этой статье я упоминал, что высота звука, подобная C♭4, помечена как имеющая более высокую октаву, чем B♯3. Это противоречит здравому смыслу, потому что, когда первый шаг транспонирован второму, он понижается на PitchLetter и Pitch.octave, оба на значение -1, но возрастает в целочисленном значении на 1 - его значение частоты в герцах увеличилось. Можно было бы ожидать, что если бы Interval.integerDistance был положительным, было бы невозможно уменьшить значение Pitch.octave, но это не так.

Согласование этого может быть достигнуто путем «нормализации» ПК перед определением, есть ли приращение или уменьшение октавного значения. Под этим я подразумеваю подталкивание ПК, который случайно движется к «естественному» направлению, и, соответственно, параллельное увеличение или уменьшение Interval.integerDistance. Возьмем, к примеру, C♭4 Pitch. Чтобы перейти от него к B♯3, используется Interval(-1, 1). Вычисляя разницу между первым шагом Accidental.modifier и вторым и добавляя его к Interval.integerDistance, мы искусственно обрабатываем транспонирование так, как если бы оно происходило между двумя Pitch без случайностей. Затем мы можем транспонировать начало Pitch на это новое значение, и если оно превосходит одну из границ Pitch октав (то есть опускается ниже C или поднимается выше B), то мы можем с уверенностью сказать, что октава либо увеличилась, либо уменьшилась.

Давайте применим эту концепцию на практике, применив ее к нашему C♭4 -> B♯3 транспонированию через Interval(-1, 1):

  1. Узнайте разницу между "от" и "до" компьютеров Accidental.modifier и добавьте их в Interval.integerDistance. Это похоже на -1 — 1 + 1 == -1.
  2. «Новое» Interval.integerDistance, основанное на предыдущем шаге, теперь равно -1, и уменьшение целочисленного значения C4s на это заставляет его опускаться ниже границы октавы Pitch, чтобы достичь B3. Поэтому октава была уменьшена на 1.

После прочтения этого Pitch алгоритма перестановки вы можете подумать: почему бы просто не рассмотреть Interval.letterDistance вместо всего этого громоздкого сложения и вычитания? В конце концов, это уже было -1, подразумевая, что C PitchLetter пришлось бы уменьшить до B и, таким образом, переместиться на октаву ниже. В этом конкретном примере вы были бы правы; но Interval может выполнять многооктавные операции. То есть оно может иметь значение Interval.integerDistance больше 12 или меньше -12, и в этом случае нам, безусловно, придется учитывать это другое поле класса Integer при выполнении транспонирования. Поле Integer.letterDistance ограничено вселенной mod7 PitchLetters, и поэтому не будет работать для многооктавных транспозиций.

И с этим мы, наконец, достигли Pitch транспозиции. В моем репозитории GitHub для этого проекта я разместил весь код этого проекта, включая набор модульных тестов для подтверждения его эффективности. Одна замечательная особенность автоматической транспозиции заключается в том, что становится очень просто генерировать большие объемы значимых данных. Например, с помощью краткого блока кода ниже я могу распечатать 120 различных музыкальных сборников. Не стесняйтесь клонировать репозиторий и попробовать!

Pitch(PitchClass(PitchLetter.C), Octave(4u))
    .toCollection(Interval.chromaticScale).forEach { pitch ->
        println(pitch.toCollection(Interval.majorScale))
        println(pitch.toCollection(Interval.naturalMinorScale))
        println(pitch.toCollection(Interval.majorPentatonicScale))
        println(pitch.toCollection(Interval.minorPentatonicScale))
        println(pitch.toCollection(Interval.wholeToneScale))
        println(pitch.toCollection(Interval.majorOctatonicScale))
        println(pitch.toCollection(Interval.minorOctatonicScale))
        println(pitch.toCollection(Interval.circleOfFifths))
        println(pitch.toCollection(Interval.majorTriad))
        println(pitch.toCollection(Interval.minorTriad))
        println()
    }

⁰ Теория музыки не определяется в широком смысле как синтаксическая, например В анализе римских цифр определение того, какой основной аккорд использовать для модуляции, обычно является субъективным. Однако основы теории музыки, например шкалы степеней, интервалов и т. д. имеют объективный синтаксис.

¹ Это относится только к бемоль и диез - буква основного тона, состоящая из нескольких естественных случайностей, не отличается от одной естественной случайности или ее отсутствия (что подразумевает одно).

² Также существуют двойные диезы, например C♯♯, но любопытно пишется как C𝄪. Кроме того, иногда в нотах вы можете увидеть букву основного тона с двумя одинаковыми случайностями, за которыми следует одна и та же буква высоты звука с естественной и одной из ранее замеченных случайностей, например C𝄪, за которым следуетC♮♯. Это просто «синтаксический сахар» в помощь исполнителю произведения.

³ Здесь и в оставшейся части статьи я буду использовать язык программирования Kotlin.

⁴ С другой стороны, мажорная / минорная гамма действительно симметрична. Если сложить идеальные квинты или четверти, получатся эти коллекции. Например, F C G D A E B - это совокупность сложенных пятых частей, в результате которой получилась коллекцияC major.

⁵ Информацию о его реализации см .: https://github.com/nihk/MusicTheoryQuirks/blob/master/src/main/kotlin/Util.kt

https://github.com/nihk/MusicTheoryQuirks