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

Хотя это было здорово, было несколько проблем. Мы смешивали идею «музыкальной дистанции» и «физической дистанции» при расчете оптимального голосового сопровождения и не принимали во внимание воспроизводимость прогрессий, которые мы генерировали.

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

Это звучит как фантастический предлог, чтобы размять наши Эликсирные мускулы!

Расчет аппликатуры

Мы начнем наше путешествие по вычислению всех возможных аппликатур для данного гитарного аккорда, создав новый модуль Elixir, Chord.Fingering, и новую функцию fingerings/1:

defmodule Chord.Fingering do
  def fingerings(chord)
end

Наш высокоуровневый план атаки для вычисления возможных аппликатур довольно прост. Учитывая, что каждый chord представляет собой шестиэлементный массив играемых ладов, как и [nil, 5, 7, 7, 6, nil], мы хотим:

  1. Прикрепите все возможные аппликатуры, которые можно сыграть на каждом ладу.
  2. Выберите каждый возможный палец по очереди, отсейте все последующие невозможные пальцы и рекурсивно повторите, чтобы получить все возможные аппликатуры.
  3. Выполните любую необходимую очистку.

Наша последняя функция fingerings/1 делает эти шаги довольно явными:

def fingerings(chord),
  do:
    chord
    |> attach_possible_fingers()
    |> choose_and_sieve()
    |> cleanup()

Возможные пальцы? Сита?

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

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

Чтобы вернуться к нашей ситуации, представьте, что мы пытаемся сыграть ре-минорный аккорд на пятом ладу:

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

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

Если мы подумаем об этом, мы также можем отсеять возможность повторного использования нашего второго пальца. Палец нельзя использовать повторно, если только он не образует такт или дабл-стоп на соседнем ладу.

Наш оставшийся набор возможных пальцев для оставшихся нот — это третий и четвертый пальцы.

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

Выбор и просеивание

Суть нашего алгоритма заключается в функции choose_and_sieve/2, которая принимает начальное chord с «возможными пальцами» и аргумент fingerings, который по умолчанию представляет собой пустой список:

defp choose_and_sieve(chord, fingerings \\ [])

Аргумент fingerings будет использоваться для хранения выбора каждого пальца для нашего chord по мере того, как мы их выбираем.

Наша функция choose_and_sieve/1 ожидает, что каждый элемент chord будет двухэлементным кортежем, где первый элемент — это играемый лад, а второй элемент — набор возможных пальцев, которые можно выбрать для игры на этом ладу.

Наша вспомогательная функция attach_possible_fingers/1 преобразует исходный аккорд в ожидаемую структуру:

defp attach_possible_fingers(chord),
  do: Enum.map(chord, &{&1, 1..4})

Наша реализация choose_and_sieve/2 является рекурсивной, поэтому мы должны начать нашу реализацию с определения нашего базового случая. Базовый вариант для choose_and_sieve/2 срабатывает, когда chord пусто. К этому моменту мы обработали каждую ноту в аккорде и должны вернуть нашу полностью построенную аппликатуру:

defp choose_and_sieve([], fingerings),
  do:
    fingerings
    |> Enum.reverse()
    |> List.to_tuple()

Как мы скоро увидим, выбранные пальцы добавляются к fingerings в обратном порядке, поэтому мы reverse/1 наш список переориентируем наши строки. Наконец, мы превращаем наш список fingerings в кортеж, чтобы мы могли безопасно flatten/1 получить наш список из fingerings, не теряя наших группировок.

После выравнивания наша функция cleanup/1 сопоставляет этот окончательный список и преобразует каждый кортеж обратно в массив:

defp cleanup(fingerings),
  do: Enum.map(fingerings, &Tuple.to_list/1)

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

Если следующим элементом в нашем списке chord является невоспроизведенная строка (nil), мы добавляем ее в наш список fingerings и назначаем ее для воспроизведения без пальца (nil) и рекурсивно вызываем choose_and_sieve/2 для нашего оставшегося chord:

defp choose_and_sieve([{nil, _possible_fingers} | chord], fingerings),
  do: choose_and_sieve(chord, [{nil, nil} | fingerings])

Точно так же, если следующим элементом нашего chord является открытая строка, мы рекурсивно вызываем chose_and_sieve/2, передавая оставшиеся chord, и к нашему набору пальцев добавляется открытая строка, сыгранная без пальца (nil):

defp choose_and_sieve([{0, _possible_fingers} | chord], fingerings),
  do: choose_and_sieve(chord, [{0, nil} | fingerings])

В случае действительной необходимости аппликатуры ноты ситуация усложняется. В таком случае следующим элементом нашего chord будет лад и некоторый набор possible_fingers.

Мы сопоставим каждую из possible_fingers, добавим каждую finger и fret к нашему списку fingerings, отсеем любые теперь невозможные possible_fingerings из оставшихся нот в нашем chord, а затем рекурсивно вызовем нашу функцию choose_and_sieve/2 с нашими только что просеянными chord и new_fingerings :

defp choose_and_sieve([{fret, possible_fingers} | chord], fingerings),
  do:
    possible_fingers
    |> Enum.map(fn finger ->
      new_fingerings = [{fret, finger} | fingerings]

      chord
      |> sieve_chord(new_fingerings)
      |> choose_and_sieve(new_fingerings)
    end)
    |> List.flatten()

Вспомогательная функция sieve_chord/2 сопоставляет каждую из нот в том, что осталось от нашего chord, и обновляет элемент кортежа possible_fingers, чтобы отфильтровать любые аппликатуры, которые теперь считаются невозможными для воспроизведения после размещения нашего последнего пальца:

defp sieve_chord(chord, fingerings),
  do:
    chord
    |> Enum.map(fn {fret, possible_fingers} ->
      {fret, sieve_fingers(possible_fingers, fret, fingerings)}
    end)

Вспомогательная функция sieve_fingers/3 — это место, где мы принимаем реальные решения о поведении нашего алгоритма аппликатуры. Сама функция sieve_fingers/3 довольно проста. Он просто отклоняет и possible_fingers, которые наша вспомогательная функция bad_finger?/3 считает "плохими":

defp sieve_fingers(possible_fingers, fret, fingerings),
  do: Enum.reject(possible_fingers, &bad_finger?(fret, &1, fingerings))

Функция bad_finger?/3 прогоняет каждую комбинацию finger/fret по четырем правилам, используемым нашим алгоритмом, чтобы определить, является ли выбор пальца «невозможным», и должен быть отбракован из нашего набора possible_fingers:

defp bad_finger?(fret, finger, fingerings),
  do:
    Enum.any?([
      fret_above_finger_below?(fret, finger, fingerings),
      fret_below_finger_above?(fret, finger, fingerings),
      same_finger?(fret, finger, fingerings),
      impossible_bar?(fret, finger, fingerings)
    ])

Если какое-либо из этих правил нарушается, палец отклоняется.

Первые два правила проверяют, нужно ли растягивать возможный палец поверх или под уже размещенный палец, соответственно:

defp fret_above_finger_below?(fret, finger, [{new_fret, new_finger} | _]),
  do: fret > new_fret && finger < new_finger

defp fret_below_finger_above?(fret, finger, [{new_fret, new_finger} | _]),
  do: fret < new_fret && finger > new_finger

Третье правило подтверждает, что ни один палец не может быть использован дважды, кроме как при исполнении такта или двойной остановки на соседних ладах:

defp same_finger?(fret, finger, [{new_fret, new_finger} | _]),
  do: finger == new_finger && fret != new_fret

Наконец, нам нужно предотвратить «невозможные такты» или такты, которые приглушают ноты, играемые на нижних ладах:

defp impossible_bar?(_fret, finger, fingerings = [{new_fret, _} | _]),
  do:
    fingerings
    |> Enum.filter(fn {fret, _finger} -> fret > new_fret end)
    |> Enum.map(fn {_fret, finger} -> finger end)
    |> Enum.member?(finger)

Результаты

Теперь, когда мы реализовали наш алгоритм аппликатуры, давайте попробуем несколько примеров.

Мы начнем с расчета возможных аппликатур для аккорда ре минор, который мы использовали в качестве примера. Предложения по аппликатуре перечислены под каждой строкой:

[nil, 5, 7, 7, 6, nil]
|> Chord.Fingering.fingerings()
|> Enum.map(&Chord.Renderer.to_string/1)
|> Enum.join("\n\n")
|> IO.puts

Потрясающий! Первый предложенный такт может быть трудным для воспроизведения, но с некоторой практикой в ​​выполнении двойных остановок в стиле Теда Грина это выполнимо. Второе и третье предложения — это то, к чему я обычно стремлюсь.

Еще один интересный пример — открытая форма соль мажор:

[3, 2, 0, 0, 3, 3]
|> Chord.Fingering.fingerings()
|> Enum.map(&Chord.Renderer.to_string/1)
|> Enum.join("\n\n")
|> IO.puts

Первые несколько предложений по аппликатурам имеют смысл, но по мере того, как мы приближаемся к концу списка, некоторые из предложений играть становится все труднее. Я не думаю, что когда-нибудь смогу сыграть эту аппликатуру:

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

Что дальше?

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

Если набор аккордов имеет одинаковое «музыкальное расстояние» от заданного начального аккорда, я хочу выбрать аккорд с наименьшим «физическим расстоянием». Под «физической дистанцией» я имею в виду буквальное расстояние между ладами, а также сложность перехода от одного аккорда к другому. Мне просто нужно провести одним пальцем? Это просто! Нужно ли поднимать и заменять три пальца, сдвигая четвертый? Это не так просто…

Мы не можем рассчитать физическое расстояние между аккордами, если не знаем аппликатуры рассматриваемых аккордов. Теперь, когда мы знаем возможные аппликатуры для данного аккорда, мы можем вычислить (модифицированное) расстояние Левенштейна между аппликатурами двух аккордов!

Почему это круто?

Как только это будет сделано, мы сможем взять стартовый аккорд (опционально с начальной аппликатурой) и найти наилучшее звучание посадочного аккорда с точки зрения голосового сопровождения и простоты игры!

Обязательно ознакомьтесь со всем проектом на Github и следите за новостями.

Первоначально опубликовано на сайте www.petecorey.com 13 августа 2018 г.