一种将吉他和弦转换为Python弹钢琴乐谱的明智方法
#python #算法 #music

- 嘿,您可以播放歌曲X?

只是一个随机朋友来参加您主持的聚会的一个看似无辜的问题。但是,如果您学会了在童年时代弹钢琴并且偶尔只是为了好玩而弹奏钢琴,那可能会很痛苦。麻烦的是,我在耳边玩耍时绝对没有用,所以我唯一的演奏机会就是握住音乐表。因此,我的第一步是去和Google“ x 乐谱pdf”或“ x 人声得分pdf”,具体取决于 x 是什么。不幸的是,您经常遇到以下问题:

  1. 钢琴得分存在,但仅包含旋律本身 +一些非常有限的伴奏。
  2. 钢琴得分存在,但已发行。
  3. 钢琴根本没有版本。

您向要求您玩 X 的朋友报告了您的发现,他们会像:

- 哦,可惜我没有带吉他,我知道和弦,只是我不弹钢琴...

在这一点上,您可能会想着自己:啊,我很久以前就学到了一些音乐理论,仅仅通过阅读他们的名字就可以在钢琴上弹奏和弦,否则...它?

事实证明,如果您以前从未做过,那么它比观看适当的音乐得分要困难得多。您需要很好地思考以在精神上解码和弦(如果您遇到一些更复杂的符号的符号,这会变得更糟),并找到一种合理的方式来用两只手玩它们。我可以向您保证,您将发现自己是混乱的。 Indeed, it's not immediately obvious that in the chord sequence d m f f f a m \mathrm{D_m\,F\,A_m} you can just move one single finger by a tone or semitone instead of shifting the entire hand (or rather, two hands) to the right by a minor or major third.

This is where I remembered I was a software engineer after all, not a musician. So, instead of practicing I decided to simply write a script that will make my life easier.

The rest of this article is broken into four parts:

  1. Working with guitar chords in Python
  2. Generating a piano score
  3. Optimizing piano chord representation
  4. Assembling everything together

I chose Python as a programming language here because, as you could probably guess, there are myriads of libraries in Python for working with music. I found koude00最简单的是使用吉他和弦和koude1生成钢琴得分。不过,只能使用Music21来完成所有操作,因为它非常强大,但是我发现将两个库的混合比使用Music21实现Mingus所需的功能更容易。

无论如何,您在下面看到的大多数代码都是使用Google Colaboratory作为IDE编写的,您可以在文章末尾找到指向它的链接。

与吉他和弦一起工作

正如人们所说的,有多种方法可以使猫皮肤。同样,大多数和弦都有几种吉他的表示,即使大多数业余吉他手通常会使用每个和弦的单一表示。 mingus具有一个不错的功能,可以生成所有合理的演奏方式:

from mingus.extra.tunings import get_tuning
from mingus.containers import NoteContainer, Note

guitar_tuning = get_tuning('guitar', 'standard')

guitar_tuning.find_chord_fingering(NoteContainer().from_chord('Dbm6'))

<摘要>输出:[[0,1,2,1,2,0],...]
[[0, 1, 2, 1, 2, 0],
 [0, 1, 2, 1, 2, 4],
 [6, 7, 6, 6, 9, 6],
 [6, 7, 8, 6, 9, 6],
 [6, 7, 6, 6, 9, 9],
 [9, 7, 6, 6, 9, 6],
 [9, 11, 11, 9, 11, 9],
 [12, 11, 11, 13, 11, 12],
 [0, 1, None, 1, 2, 0],
 [0, 1, 2, 1, 2, None],
 [0, 1, None, 1, 2, 4],
 [6, 7, 6, 6, None, 6],
 [6, 7, 6, 6, 9, None],
 [6, 7, 6, 6, None, 9],
 [6, 7, None, 6, 9, 6],
 [9, 7, 6, 6, None, 6],
 [None, 7, 6, 6, 9, 6],
 [9, 11, None, 9, 11, 9],
 [12, 11, 11, None, 11, 12],
 [12, 11, 11, 13, 11, None],
 [None, 11, 11, 13, 11, 12],
 [0, 1, None, 1, 2, None],
 [6, 7, 6, 6, None, None],
 [None, 7, 6, 6, None, 6],
 [12, 11, 11, None, 11, None],
 [None, 11, 11, None, 11, 12]]

这里的数字对应于每个字符串沿吉他颈部的位置。为了使实际的音高与每个手指相对应,我们可以使用guitar_tuning.get_Note

for fingering in guitar_tuning.find_chord_fingering(NoteContainer().from_chord('Dbm6')):
  chord_pitches = [ guitar_tuning.get_Note(string=i, fret=fingering[i]) if fingering[i] is not None else None for i in range(6) ]
  print(chord_pitches)

['e-2','a#-2','e-3','g#-3','c#-4','e-4'] ...
['E-2', 'A#-2', 'E-3', 'G#-3', 'C#-4', 'E-4']
['E-2', 'A#-2', 'E-3', 'G#-3', 'C#-4', 'G#-4']
['A#-2', 'E-3', 'G#-3', 'C#-4', 'G#-4', 'A#-4']
['A#-2', 'E-3', 'A#-3', 'C#-4', 'G#-4', 'A#-4']
['A#-2', 'E-3', 'G#-3', 'C#-4', 'G#-4', 'C#-5']
['C#-3', 'E-3', 'G#-3', 'C#-4', 'G#-4', 'A#-4']
['C#-3', 'G#-3', 'C#-4', 'E-4', 'A#-4', 'C#-5']
['E-3', 'G#-3', 'C#-4', 'G#-4', 'A#-4', 'E-5']
['E-2', 'A#-2', None, 'G#-3', 'C#-4', 'E-4']
['E-2', 'A#-2', 'E-3', 'G#-3', 'C#-4', None]
['E-2', 'A#-2', None, 'G#-3', 'C#-4', 'G#-4']
['A#-2', 'E-3', 'G#-3', 'C#-4', None, 'A#-4']
['A#-2', 'E-3', 'G#-3', 'C#-4', 'G#-4', None]
['A#-2', 'E-3', 'G#-3', 'C#-4', None, 'C#-5']
['A#-2', 'E-3', None, 'C#-4', 'G#-4', 'A#-4']
['C#-3', 'E-3', 'G#-3', 'C#-4', None, 'A#-4']
[None, 'E-3', 'G#-3', 'C#-4', 'G#-4', 'A#-4']
['C#-3', 'G#-3', None, 'E-4', 'A#-4', 'C#-5']
['E-3', 'G#-3', 'C#-4', None, 'A#-4', 'E-5']
['E-3', 'G#-3', 'C#-4', 'G#-4', 'A#-4', None]
[None, 'G#-3', 'C#-4', 'G#-4', 'A#-4', 'E-5']
['E-2', 'A#-2', None, 'G#-3', 'C#-4', None]
['A#-2', 'E-3', 'G#-3', 'C#-4', None, None]
[None, 'E-3', 'G#-3', 'C#-4', None, 'A#-4']
['E-3', 'G#-3', 'C#-4', None, 'A#-4', None]
[None, 'G#-3', 'C#-4', None, 'A#-4', 'E-5']

请注意,黑色笔记(或更确切地说是与钢琴键盘上的黑键相对应的音符)现在始终带有 \sharp , never with a \flat . The notes for the Dm6\mathrm{D♭m6} chord should technically consist of D,F,A,B\mathrm{D\flat, F\flat, A\flat, B\flat\kern-1.4pt\flat} , while C,E,G,A\mathrm{C\sharp, E, G\sharp, A\sharp} would refer to Cm6\mathrm{C\sharp m6} . This doesn't matter much for a guitar — after all, the pitches are the same, however, it gets much more important for a piano score since the chords should match the current key signatures. I will show how to address this issue in the next section.

产生钢琴得分

用于使用钢琴分数,我将使用库music21

这是手动生成钢琴分数的最基本方法:

import music21 as m21
sc1 = m21.stream.Score()
right_hand = m21.stream.PartStaff()
left_hand = m21.stream.PartStaff()
right_hand.append(m21.key.Key('Ab', 'major'))
right_hand.append(m21.note.Note('D-4'))
right_hand.append(m21.note.Note('F-4'))
right_hand.append(m21.note.Note('A-4'))
right_hand.append(m21.note.Note('B--4'))
left_hand.append(m21.chord.Chord(
    [m21.note.Note('D-3'), m21.note.Note('A-3')],
    duration=m21.duration.Duration(4)))
sc1.insert(0, right_hand)
sc1.insert(0, left_hand)
sc1.insert(0, m21.layout.StaffGroup([right_hand, left_hand], symbol='brace'))

sc1.show()

Output of the code above

您已经可以注意到music21mingus的一些差异,它们表示注释:

  1. music21使用#-进行更改标记;
  2. mingus使用#b,而-只是音符和八度之间的分离器。

如果您尝试简单地按名称从一个库中映射到另一个库的注释,这可能会产生有趣的效果:

mingus_note = Note('A-6')
print(mingus_note.to_hertz())
print(m21.pitch.Pitch(str(mingus_note).strip("'")).freq440)  # BAD!
print(m21.pitch.Pitch(mingus_note.name, octave=mingus_note.octave).freq440) # GOOD
print()

# Music21 can parse flats at the input (bemols)
mingus_note = Note('Db-5')
print(mingus_note.to_hertz())
print(m21.pitch.Pitch(mingus_note.name, octave=mingus_note.octave).freq440)
print()

# ... but not double-flats.
mingus_note = Note('Dbb-5')
print(mingus_note.to_hertz())
try:
  print(m21.pitch.Pitch(mingus_note.name, octave=mingus_note.octave).freq440)
except Exception as e:
  print(e)

上述块的输出为:

1760.0
1661.218790319782
1760.000000000002

554.3652619537442
554.3652619537443

523.2511306011972
bb is not a supported accidental type

现在,让我们定义一些简单的语言来描述我们的和弦序列:每个和弦都将被指定为duration:chord_symbol,其中duration在crotchets(季度)中指定了durationchord_symbol用吉他符号:

4:Am 4:H7 2:E 2:E7 8:Am 4:H7 2:E 2:E7 4:Am

您可能在这里被H注释所困扰 - 是的,这首歌显然遵循了德国和弦符号的传统。德语和英文音符名称之间的唯一重要区别是B和H:

英语 德语
b \mathrm{B} H\mathrm{H}
B\mathrm{B\flat} B\mathrm{B}

Converting from German to English is then pretty trivial:

daima5

Here is what we will do — take the first fingering proposed by koude0, play the lower 3 strings with the left hand and the upper 3 strings with the right hand.

code
koude21

Output of the code above

正如预期的那样,通过和弦 - 音调序列,我们丢失了有关和弦正确改动标记的所有信息。在上面的示例中,您可能会注意到 b \mathrm{B\flat} is represented as A\mathrm{A\sharp} . So let's try to get things right by altering the notes in the chord to valid enharmonics:

code
koude22

Output of the code above

这要好得多,但是我们仍然无法控制时间签名 - 是 < < Mn> 4 4 \mathrm{\frac{4}{4}} by default, or the key signature — it's C\mathrm{C} (or rather, Am\mathrm{Am} , like in the example above). To correct this we will allow adding arbitrary TinyNotation元素都归因于这两个五线段。由于TinyNotation不支持关键更改,因此我们将扩展其功能。

新解析器将支持分数描述:

[kDm 3/4] 2:F 1:Gm 1:Am 2:AmM7 6:Dm

同时,当我们使用它时,我们还将解析和渲染分开,因为以后我们将在它们之间有一个优化步骤:

更多代码
import re
import functools
import copy

class KeyToken(m21.tinyNotation.Token):
    def parse(self, parent):
        keyName = self.token
        return m21.key.Key(keyName)


class BaseElement:
  def add_to_score(self, score: m21.stream.Score): ...


@functools.cache
def get_harmony_and_implementations(chord_symbol: str):
  harmony = get_harmony_symbol_from_name(chord_symbol)
  mingus_chord = NoteContainer().from_chord(chord_symbol)
  return harmony, tuple(
      get_hands_from_notes(
          fix_enharmonisms(
              notes_from_fingering(fingering),
              harmony
          ),
      )
      for fingering in guitar_tuning.find_chord_fingering(mingus_chord)
  )


class ChordElement(BaseElement):

  def __init__(self, token: str):
    duration, chord_symbol = token.split(':')
    self.duration = m21.duration.Duration(quarterLength=float(duration))
    chord_symbol = de_germanize(chord_symbol)
    self.harmony, self.implementations = get_harmony_and_implementations(chord_symbol)
    self.best_implementation_index = 0

  def add_to_score(self, score: m21.stream.Score):
    chord_2h = self.implementations[self.best_implementation_index]
    right_hand, left_hand = score.parts
    right_hand.append(copy.deepcopy(self.harmony))  # Copy to prevent "object already added"
    right_hand.append(chord_or_rest_from_notes(chord_2h.right_hand_notes, duration=self.duration))
    left_hand.append(chord_or_rest_from_notes(chord_2h.left_hand_notes, duration=self.duration))


class MetadataElement(BaseElement):
  def __init__(self, title: str):
    self.metadata = m21.metadata.Metadata(title=title)

  def add_to_score(self, score: m21.stream.Score):
    score.insert(0, self.metadata)


class RemarkElement(BaseElement):
  def __init__(self, text: str):
    self.expression = m21.expressions.TextExpression(text)
    self.expression.placement = 'above'

  def add_to_score(self, score: m21.stream.Score):
    score.parts[0].append(self.expression)


class TinyNotationElement(BaseElement):
  def __init__(self, token: str):
    assert(token[0] == '[' and token[-1] == ']')
    tnc = m21.tinyNotation.Converter(makeNotation=False)
    tnc.tokenMap.append((r'k(.*)', KeyToken))
    tnc.load(token[1:-1])
    tnc.parse()
    self.stream = tnc.stream

  def add_to_score(self, score: m21.stream.Score):
    for sub_element in self.stream:
      for part in score.parts:
        part.append(sub_element)


def parse_chords(chords_string: str) -> List[BaseElement]:
  result = []
  PATTERNS = (
      ('TINY', r'\[[^\]]+\]'),
      ('TITLE', r'TITLE\[(?P<TITLE_TEXT>[^\]]+)\]'),
      ('REMARK', r'REMARK\[(?P<REMARK_TEXT>[^\]]+)\]'),
      ('CHORD', r'\d+(\.\d*)?:\S+'),
      ('SPACE', r'\s+'),
      ('ERROR', r'.')
  )
  full_regex = '|'.join(f'(?P<{kind}>{expression})' for kind, expression in PATTERNS)
  for mo in re.finditer(full_regex, chords_string):
    token = mo.group()
    kind = mo.lastgroup
    if kind == 'TINY':
      result.append(TinyNotationElement(token))
    elif kind == 'TITLE':
      result.append(MetadataElement(mo.group('TITLE_TEXT')))
    elif kind == 'REMARK':
      result.append(RemarkElement(mo.group('REMARK_TEXT')))
    elif kind == 'CHORD':
      result.append(ChordElement(token))
    elif kind == 'SPACE':
      pass
    else: # Error
      raise RuntimeError(f'Unexpected token at position {mo.start()}: "{chords_string[mo.start():]}"')
  return result

def score_from_chords(chords_string: str):
  elements = parse_chords(chords_string)

  score, _, _ = init_score_and_hands()
  for element in elements:
    element.add_to_score(score)
  return score

score_from_chords('TITLE[Song of a page] [kDm 3/4] 2:F 1:Gm 1:Am 2:AmM7 REMARK[Arpeggios] 6:Dm').show()

Output of the code block above

优化钢琴和弦表示

为了选择最佳和弦序列,我们将使用以下模型:

  1. 每个和弦实现都将具有相关的计算困难。
  2. 在分数中相互追随两个和弦实现之间的过渡也将有一个计算的难度。

我们旨在最大程度地减少所有和弦和过渡的总难度。这可以使用动态编程方法或图中的最短路径搜索完成。

无论如何,让我们从和弦难度开始,因为这样的优化变得琐碎 - 只需从列表中选择最佳和弦:

<摘要>描述和弦难度各个组成部分的代码
def chord_length_penalty(chord_2h: ChordForTwoHands) -> int:
  num_notes = len(chord_2h.left_hand_notes) + len(chord_2h.right_hand_notes)
  # Prefer chords with 5 notes across both hands and but force at least 3.
  return [400, 300, 200, 15, 5, 0, 2][num_notes]

def clef_mismatch_penalty(chord_2h: ChordForTwoHands) -> int:
  middle_c = m21.pitch.Pitch('C4')
  req_bars = 0
  if chord_2h.left_hand_notes:
    req_bars += max(chord_2h.left_hand_notes[-1].diatonicNoteNum - middle_c.diatonicNoteNum, 0)
  if chord_2h.right_hand_notes:
    req_bars += max(middle_c.diatonicNoteNum - chord_2h.right_hand_notes[0].diatonicNoteNum, 0)
  return req_bars * 3


def duplicate_note_penalty(chord_2h: ChordForTwoHands) -> int:
  all_notes = chord_2h.left_hand_notes + chord_2h.right_hand_notes
  # Make sure we don't attempt to put the same 
  if len(all_notes) != len(set(x.diatonicNoteNum for x in all_notes)):
    return 100
  return 0


def is_white_key(p: m21.pitch.Pitch) -> bool:
  return p.pitchClass not in (1, 3, 6, 8, 10)


def rakhmaninoff_penalty(pitches: Sequence[m21.pitch.Pitch]) -> int:
  if not pitches:
    return 0
  # Slightly penalize chords above one octave in span.
  if pitches[0].ps - pitches[-1].ps > 12.0:
    return 1
  else:
    return 0


def black_thumb_penalty(pitches: Sequence[m21.pitch.Pitch]) -> int:
  if len(pitches) < 2 or is_white_key(pitches[0]):
    return 0
  if len(pitches) == 2 and pitches[0].ps - pitches[1].ps <= 7.0:
    # Assume that up to pure fifth we can play the interval with other fingers.
    return 0
  else:
    return 4


def black_pinkie_penalty(pitches: Sequence[m21.pitch.Pitch]) -> int:
  # Penalize 5th finger on black keys when the middle key is white
  if len(pitches) < 3:
    # No middle key means many good options to play it.
    return 0
  if is_white_key(pitches[-1]) or not is_white_key(pitches[-2]):
    return 0
  return 6


def hand_penalty(pitches: Sequence[m21.pitch.Pitch]) -> int:
  return rakhmaninoff_penalty(pitches) + black_thumb_penalty(pitches) + black_pinkie_penalty(pitches)

# Since get_harmony_and_implementations is also cached,
# We will only compute difficulty per every chord implementation at most once.
@functools.cache
def chord_difficulty(chord_2h: ChordForTwoHands) -> float:
  return (
      hand_penalty(chord_2h.left_hand_notes) +
      hand_penalty(chord_2h.right_hand_notes[::-1]) +
      duplicate_note_penalty(chord_2h) +
      chord_length_penalty(chord_2h) +
      clef_mismatch_penalty(chord_2h)
  )


def optimize_chords(elements: Sequence[BaseElement]):
  for element in elements:
    if isinstance(element, ChordElement):
      difficulties = list(map(chord_difficulty, element.implementations))
      element.best_implementation_index = min(range(len(difficulties)), key=difficulties.__getitem__)


def score_from_chords(chords_string: str):
  elements = parse_chords(chords_string)
  optimize_chords(elements)

  score, _, _ = init_score_and_hands()
  for element in elements:
    element.add_to_score(score)
  return score

score_from_chords('2:Gm 2:A7 2:Dm 2:B 2:Gm 2:Gm6 2:A7 2:Dm').show()

Output of the code block above

现在让我们建模两个和弦之间移动手的困难。我们将假设和弦之间的距离与其相应说明之间的距离之和相同。如果和弦有不同的长度,这显然将行不通,因此我们将尝试使用动态编程来匹配从一个和弦到另一和弦的笔记:

import numpy as np

def pitch_distance(first: m21.pitch.Pitch, second: m21.pitch.Pitch):
  return abs(int(first.ps) - int(second.ps))

def hand_distance(first: Sequence[m21.pitch.Pitch], second: Sequence[m21.pitch.Pitch]):
  if len(first) < len(second):
    first, second = second, first
  n = len(first)
  m = len(second)
  # m <= n
  ADD_REMOVE_PENALTY = 1
  d = np.ones((n + 1, m + 1), dtype=int) * 100000
  d[:, 0] = np.arange(n + 1) * ADD_REMOVE_PENALTY
  for i in range(1, n + 1):
    for j in range(1, m + 1):
      d[i, j] = min(
          d[i - 1, j] + ADD_REMOVE_PENALTY,
          d[i-1, j-1] + pitch_distance(first[i - 1], second[j - 1])
      )
  return d[n, m]

@functools.cache
def chord_distance(previous_chord_2h: ChordForTwoHands, current_chord_2h: ChordForTwoHands) -> int:
  return (
      hand_distance(previous_chord_2h.left_hand_notes, current_chord_2h.left_hand_notes) +
      hand_distance(previous_chord_2h.right_hand_notes, current_chord_2h.right_hand_notes)
  )

让我们表示单个和弦实施的复杂性(chord_difficulty)为 c x c(X) and complexity of the transition between two chords (koude27) as d(X,Y)d(X, Y) . Let w(X,Y)w(X, Y) be c(Y)+d(X,Y)c(Y) + d(X, Y) . Then the graph used for optimization of the chord sequence will look like this:

Graph for chord optimization using shortest paths

现在,我们的优化问题等同于在此图中找到最短路径。我正在使用Module networkx的Dijkstra算法的实现,但是由于该图的性质(它没有循环并且具有明显的拓扑顺序),如果您找到了更有效的方法。

代码
import networkx as nx

def optimize_chords(elements: Sequence[BaseElement]):
  graph = nx.DiGraph()
  graph.add_node('source')
  last_layer = ['source']

  for index, element in enumerate(elements):
    if isinstance(element, ChordElement):
      new_last_layer = []
      for impl_idx, impl in enumerate(element.implementations):
        n = f'{index}-{impl_idx}'
        graph.add_node(n, element=element, implementation_index=impl_idx, implementation=impl)
        for prev_node in last_layer:
          graph.add_edge(prev_node, n)
        new_last_layer.append(n)
      last_layer = new_last_layer

  graph.add_node('target')
  for n in last_layer:
    graph.add_edge(n, 'target')
  # Use weight function instead of explicit vertex/edge weights because
  # this will allow to perform difficulty/distance computation lazily.
  def weight_func(u, v, e):
    u_attr = graph.nodes[u]
    v_attr = graph.nodes[v]
    KEY = 'implementation'
    if KEY in v_attr:
      w = chord_difficulty(v_attr[KEY])
      if KEY in u_attr:
        w += chord_distance(u_attr[KEY], v_attr[KEY])
      return w
    else:
      return 0 
  path = nx.algorithms.dijkstra_path(graph, 'source', 'target', weight=weight_func)
  for node in path:
    attrs = graph.nodes[node]
    if 'element' in attrs:
      attrs['element'].best_implementation_index = attrs['implementation_index']

score_from_chords('2:Gm 2:A7 2:Dm 2:B 2:Gm 2:Gm6 2:A7 2:Dm').show()

Output of the code block above

将所有内容组装在一起

我们终于准备好对一首完整的歌曲进行测试:

score = score_from_chords("""
TITLE[Song of a page]
REMARK[transpose up by one tone (+2),
if the instrument supports it]

[kAm]

4:Am 4:H7 2:E 2:E7 8:Am
4:H7 2:E 2:E7 4:Am
2:Dm 2:G7 2:C 2:F 2:Dm 2:E7 4:Am
2:Gm 2:A7 2:Dm 2:B 2:Gm 2:Gm6 2:A7 2:Dm REMARK[(da capo)] [2/4] 2:Dm6
REMARK[(ending)] [4/4] 4:Gm 4:H7 4:E7 1:Am 1:E 2:Am
""").show()

Full score

可以找到完整的代码here,随意复制和播放。

本文旨在显示概念证明。因此,完全期望此代码不会适合每首歌。这是该原型中一些重要的缺乏特征:

  1. 带有低音符号的和弦(就像 e / a \ Mathrm {e/a} < SPAN class =“ Strut” style =“高度:1EM;垂直align:-0.25em;”> e/a
  2. 休息
  3. 歌词
  4. 重复(然后整个优化问题变得更加困难)
  5. 将和弦的长度调整到其持续时间

其中一些相对实施相对微不足道,而另一些(例如低音符号和重复)需要更深层的更改,甚至可能完全重新设计整个方法。

尽管如此,非常感谢您的阅读,直到最后,如果我设法激发您为音乐发电的代码写一些代码,甚至只是为了清理您可以演奏的任何音乐乐器的灰尘。<<<,我会很高兴。 /p>