LoginSignup
1
1

More than 1 year has passed since last update.

歌詞付きMIDIをMusicXMLに変換 その2:音符の整理

Last updated at Posted at 2022-12-04

概要

前回、XF仕様のMIDIファイルから歌詞を抽出することができました。
今回は音符情報からMusicXML作成に必要な要素を計算していきます。
流れは以下のような感じです。

  • 音符データの読み込み
  • 開始時間の取得
  • 音符の持続時間を取得
  • 音楽時間情報の取得
  • 音符を小節に分ける
  • タイの処理
  • 拍子の取得
  • 休符の挿入

シリーズ一覧
歌詞付きMIDIをMusicXMLに変換 リンクまとめ

環境

Python 3.8.6 です。

準備

必要ライブラリのインポート

必要ライブラリをインポートします(あとで使うものもまとめて)

import pandas as pd
import re
import jaconv
import copy
import xml.etree.ElementTree as ET
import itertools
from sudachipy import tokenizer
from sudachipy import dictionary
from typing import TypedDict, Union, List
import json
from pathlib import Path
import mido

型の定義

今回使用する変数の型を定義しておきます(あまり有効活用できていませんが)。

class Note(TypedDict, total=False):
  type: str
  channel: int
  velocity: int
  note: int
  start_time: int
  duration_time: int
  start_division: int
  duration_division: int
  lyric_raw: str
  lyric_surface: str
  lyric_pronunciation: str

class Measure(TypedDict, total=False):
  start_time: int
  measure_id: int
  duration_time: int
  duration_division: int
  notes: List[Note]
  time_information: dict


class TimeInformation(TypedDict, total=False):
  numerator: int
  denominator: int
  ticks_per_beat: int
  start_time: int
  tempo: int
  notated_32nd_notes_per_beat: int
  ticks_per_measure: int
  start_measure_id: int
  division_note: int
  divisions_per_measure: int
  ticks_per_division: int

音符データの読み込み

midoでMIDIを読み込みます。前回作ったXFMidiFileクラスでもよいです。今回は音符の情報のみを扱うため、どちらでも結果は同じです。

MIDIは前回同様、ヤマハのミュージックサイトから購入した「夜に駆ける」を使っています。各自の環境に合わせてください。

filepath = "song/yorunikakeru/yorunikakeru.mid"
midi = mido.MidiFile(filepath)
# 音符などの情報はmidi.tracks[0]にある
print(midi.tracks[0][:10])
MidiTrack([
  MetaMessage('key_signature', key='Cm', time=0),
  MetaMessage('sequencer_specific', data=(67, 123, 0, 88, 70, 48, 50, 0, 27), time=0),
  MetaMessage('track_name', name='yo ni kakeru', time=0),
  MetaMessage('copyright', text='2020 Yamaha Music Entertainment Holdings,Inc.', time=0),
  MetaMessage('time_signature', numerator=4, denominator=4, clocks_per_click=24, notated_32nd_notes_per_beat=8, time=0),
  MetaMessage('set_tempo', tempo=600000, time=0),
  MetaMessage('sequencer_specific', data=(67, 123, 16, 32, 0, 0, 64, 59, 55, 50, 45, 40), time=0),
  MetaMessage('sequencer_specific', data=(67, 123, 36, 224), time=0),
  Message('sysex', data=(126, 127, 9, 1), time=0),
  Message('sysex', data=(67, 16, 76, 0, 0, 126, 0), time=480)])

扱いやすいように、tracksの要素をdictに変換します。

messages = [m.dict() for m in midi.tracks[0]]
for m in messages[:10]:
  print(m)
{'type': 'key_signature', 'key': 'Cm', 'time': 0}
{'type': 'sequencer_specific', 'data': (67, 123, 0, 88, 70, 48, 50, 0, 27), 'time': 0}
{'type': 'track_name', 'name': 'yo ni kakeru', 'time': 0}
{'type': 'copyright', 'text': '2020 Yamaha Music Entertainment Holdings,Inc.', 'time': 0}
{'type': 'time_signature', 'numerator': 4, 'denominator': 4, 'clocks_per_click': 24, 'notated_32nd_notes_per_beat': 8, 'time': 0}
{'type': 'set_tempo', 'tempo': 600000, 'time': 0}
{'type': 'sequencer_specific', 'data': (67, 123, 16, 32, 0, 0, 64, 59, 55, 50, 45, 40), 'time': 0}
{'type': 'sequencer_specific', 'data': (67, 123, 36, 224), 'time': 0}
{'type': 'sysex', 'time': 0, 'data': [126, 127, 9, 1]}
{'type': 'sysex', 'time': 480, 'data': [67, 16, 76, 0, 0, 126, 0]}

開始時間の取得

MIDI(というかmido)では、音符やメタ情報などの単位をmessageと呼びます。
各messageには「time」というキーがあり、これは直前のmessageからの差時間を意味します。
今後処理していく上で、各messageが開始する絶対時間が必要なので、計算しておきます。itertools.accumulateを使って差時間の累積値を計算すればよいです。

以降、MIDI2MusicXMLというクラスのメソッドとして関数を定義していきます。

class MIDI2MusicXML:
  @staticmethod
  def _add_start_time(messages, *
                                , start_key = "start_time"
                                , deltatime_key = "time"):
    # 開始時間を付与
    deltatimes = [v[deltatime_key] for v in messages]
    start_times_iter = itertools.accumulate(deltatimes)
    messages = copy.deepcopy(messages)
    messages_with_start_time = []
    for v, s in zip(messages, start_times_iter):
      v[start_key] = s
      messages_with_start_time.append(v)
    return messages_with_start_time

使ってみます。

messages = MIDI2MusicXML._add_start_time(messages)
for m in messages[:10]:
  print(m)
{'type': 'key_signature', 'key': 'Cm', 'time': 0, 'start_time': 0}
{'type': 'sequencer_specific', 'data': (67, 123, 0, 88, 70, 48, 50, 0, 27), 'time': 0, 'start_time': 0}
{'type': 'track_name', 'name': 'yo ni kakeru', 'time': 0, 'start_time': 0}
{'type': 'copyright', 'text': '2020 Yamaha Music Entertainment Holdings,Inc.', 'time': 0, 'start_time': 0}
{'type': 'time_signature', 'numerator': 4, 'denominator': 4, 'clocks_per_click': 24, 'notated_32nd_notes_per_beat': 8, 'time': 0, 'start_time': 0}
{'type': 'set_tempo', 'tempo': 600000, 'time': 0, 'start_time': 0}
{'type': 'sequencer_specific', 'data': (67, 123, 16, 32, 0, 0, 64, 59, 55, 50, 45, 40), 'time': 0, 'start_time': 0}
{'type': 'sequencer_specific', 'data': (67, 123, 36, 224), 'time': 0, 'start_time': 0}
{'type': 'sysex', 'time': 0, 'data': [126, 127, 9, 1], 'start_time': 0}
{'type': 'sysex', 'time': 480, 'data': [67, 16, 76, 0, 0, 126, 0], 'start_time': 480}

ほとんどゼロなのでわかりにくいですが必要な情報はとれています。

音符の持続時間を取得

音符の持続時間を計算します。
MIDIでは音符の開始と終了は別々に定義されているので、その情報を統合する必要があります。
以下では、ループのなかで、音符の開始(note_on)の開始時間を保持しておき、同じピッチ(音階)の終了(note_off)が現れたら差時間を計算する、という処理をしています。

  @staticmethod
  def _get_duration_times(note_ons, note_offs):
    note_ons = [("on", pitch,start_time) for pitch, start_time in note_ons]
    note_offs = [("off", pitch,start_time) for pitch, start_time in note_offs]
    # note_onとoffをまとめてstart_timeの昇順
    notes = sorted(note_ons+note_offs, key=lambda x: x[-1])

    duration_times = []
    start_times = []
    pitch_to_note_pos = {}
    for i, (type_, pitch, start_time) in enumerate(notes):
      if type_ == "on":
        duration_times.append(-1)
        start_times.append(start_time)
        pitch_to_note_pos[pitch] = len(start_times) - 1
      elif type_ == "off":
        if pitch in pitch_to_note_pos: 
          pos = pitch_to_note_pos[pitch]
          duration_times[pos] = start_time - start_times[pos]
        # このケースは発生しないはず
        else:
          print("there is no note corresponding this note_off")
          continue
    return duration_times

  @classmethod
  def _add_duration_time(cls, note_on_and_offs, *
                          , start_key = "start_time"
                          , pitch_key = "note"
                          , type_key = "type"
                          , duration_key = "duration_time"
                          , note_on_type = "note_on"
                          , note_off_type = "note_off"):
    # 各noteの持続時間を計算
    note_ons = [(v[pitch_key], v[start_key]) for v in note_on_and_offs if v[type_key] == note_on_type]
    note_offs = [(v[pitch_key], v[start_key]) for v in note_on_and_offs if v[type_key] == note_off_type]
    duration_times = cls._get_duration_times(note_ons, note_offs)
    notes = [v.copy() for v in note_on_and_offs if v[type_key] == note_on_type]
    notes_with_duration_time = []
    for note, duration_time in zip(notes, duration_times):
      note[duration_key] = duration_time
      notes_with_duration_time.append(note)
    return notes_with_duration_time

コードについて補足します。
まず音符の開始、終了に対応するmessageはそれぞれtype=note_on, note_offとなっているので、その条件で抽出します。
またMIDIでは、フォーマットによりますが、channelというキーによって、パートを指定します。今回はメロディのパートだけ処理をするので、特定のチャンネル番号(今回は0)で絞っています。メロディが何チャンネルであるかは、XF仕様のカラオケメッセージ情報などから取得可能です(ヤマハの場合は、だいたい0のようです)

実際の持続時間の計算では、一つのチャネルのあるピッチ(音階)の音は、一度開始されたあと、終了前に再度開始されることはない、という仮定をおいています(note_onがきたら、つぎのnote_onが来るまでに必ずnote_offが生じている)。一つのパートで、音を止める前に同じ音を鳴らすことはないですから、自然な仮定ですし、ある程度出所のしっかりしたMIDIであれば、それが破られることはあまりないかと思います。

上記の仮定のもとで、基本的にはnote_onだけ抽出し、pitchとnote_onのindexを覚えておいて、note_offがきたら、同じpitchの直近のnote_onとの差時間をduration_timeとしてnote_onの要素に追加する、という処理を実施しています。

上記の仮定が満たされない場合のエラー処理もそんなに難しくなく書けると思いますが、今は省略しています。

実行してみます。
ついでに、型をNote型に直しておきます。


# チャンネル0のnote_onまたはnote_offタイプのメッセージを抽出
note_on_and_offs = [m for m in messages if m["type"] in ("note_on", "note_off") and m.get("channel", -1) == 0]
notes = MIDI2MusicXML._add_duration_time(note_on_and_offs)
# Note型に直す
temp: List[Note] = []
for note in notes:
      note: Note = {
        "type": note["type"]
        , "start_time": note["start_time"]
        , "duration_time": note["duration_time"]
        , "note": note["note"]
      }
      temp.append(note)
notes = temp
for note in notes[:10]:
  print(note)
{'type': 'note_on', 'start_time': 5280, 'duration_time': 238, 'note': 79}
{'type': 'note_on', 'start_time': 5520, 'duration_time': 238, 'note': 82}
{'type': 'note_on', 'start_time': 5760, 'duration_time': 358, 'note': 84}
{'type': 'note_on', 'start_time': 6120, 'duration_time': 358, 'note': 80}
{'type': 'note_on', 'start_time': 6480, 'duration_time': 238, 'note': 79}
{'type': 'note_on', 'start_time': 6720, 'duration_time': 238, 'note': 77}
{'type': 'note_on', 'start_time': 6960, 'duration_time': 238, 'note': 75}
{'type': 'note_on', 'start_time': 7200, 'duration_time': 238, 'note': 77}
{'type': 'note_on', 'start_time': 7440, 'duration_time': 238, 'note': 84}
{'type': 'note_on', 'start_time': 7680, 'duration_time': 238, 'note': 82}

音楽時間情報の取得

tempoや拍子の情報(ここではまとめて時間情報/time_informationとよぶことにします。を取得します。
midiでは時間情報は途中変更されうるので開始時間とともにリストで保持するようにします。

テンポはset_tempo、拍子等はtime_signatureというmessageのtypeで表記されるので、それらのmessageが登場したら、リストの要素を追加します。基本的には、set_tempoとtime_signatureのどちらか一方が登場した瞬間にリストを更新します。ただし、持続時間0の時間情報を存在させないため、テンポと拍子の情報が差時間0で登場したときは、追加ではなくリストの最後の要素を編集します。

class MIDI2MusicXML:
  DEFAULT_TICKS_PER_BEAT = 480
  DEFAULT_TEMPO = 60000
  DEFAULT_NUMERATOR = 4
  DEFAULT_DENOMINATOR = 4
  DEFAULT_NOTATED_32ND_NOTES_PER_BEAT = 8
  DEFUALT_DIVISION_NOTE = 32
  # messagesからtime_informationの情報を取得
  @classmethod
  def _get_time_informations(cls, messages, *
                  , ticks_per_beat = None
                  , tempo = None
                  , numerator = None
                  , denominator = None
                  , notated_32nd_notes_per_beat = None
                  , division_note = None
                  ):
    ticks_per_beat = ticks_per_beat or cls.DEFAULT_TICKS_PER_BEAT
    tempo = tempo or cls.DEFAULT_TEMPO
    numerator = numerator or cls.DEFAULT_NUMERATOR
    denominator = denominator or cls.DEFAULT_DENOMINATOR
    notated_32nd_notes_per_beat = notated_32nd_notes_per_beat or cls.DEFAULT_NOTATED_32ND_NOTES_PER_BEAT
    division_note = division_note or cls.DEFUALT_DIVISION_NOTE

    time_informations: List[TimeInformation] = []

    time_information: TimeInformation = {
      "numerator": numerator
      , "denominator": denominator
      , "ticks_per_beat": ticks_per_beat
      , "start_time": 0
      , "tempo": tempo
      , "notated_32nd_notes_per_beat": notated_32nd_notes_per_beat
      , "ticks_per_measure": int(ticks_per_beat / notated_32nd_notes_per_beat * 32 / denominator * numerator)
      , "start_measure_id": 0
      , "division_note": division_note
      , "divisions_per_measure": int(division_note / denominator * numerator)
      , "ticks_per_division": int(ticks_per_beat / notated_32nd_notes_per_beat * 32 / division_note)
    }

    time_informations.append(time_information)

    for m in messages:
      if m["type"] == "set_tempo":
        new_time_information = time_informations[-1].copy()
        new_time_information["tempo"] = m["tempo"]
        new_time_information["start_time"] = m["start_time"]
        if new_time_information["start_time"] == time_informations[-1]["start_time"]:
          time_informations[-1] = new_time_information
        else:
          duration_time = m["start_time"] - time_informations[-1]["start_time"]
          time_informations[-1]["duration_time"] = duration_time
          measure_num = int(duration_time/time_informations[-1]["ticks_per_measure"])
          time_informations[-1]["measure_num"] = measure_num
          new_time_information["start_measure_id"] = time_informations[-1]["start_measure_id"] + measure_num
          time_informations.append(new_time_information)
      elif m["type"] == "time_signature":
        """todo 必ず全部あるとは限らない?"""
        new_time_information = time_informations[-1].copy()
        new_time_information["numerator"] = m["numerator"]
        new_time_information["denominator"] = m["denominator"]
        new_time_information["notated_32nd_notes_per_beat"] = m["notated_32nd_notes_per_beat"]
        new_time_information["ticks_per_measure"] = int(new_time_information["ticks_per_beat"] / m["notated_32nd_notes_per_beat"] * 32  * m["numerator"] / m["denominator"])
        new_time_information["divisions_per_measure"] = int( new_time_information["division_note"]  * m["numerator"]/ m["denominator"])
        new_time_information["ticks_per_division"] = int(new_time_information["ticks_per_beat"] / new_time_information["notated_32nd_notes_per_beat"] * 32 / new_time_information["division_note"])
        new_time_information["start_time"] = m["start_time"]
        if new_time_information["start_time"] == time_informations[-1]["start_time"]:
          time_informations[-1] = new_time_information
        else:
          duration_time = m["start_time"] - time_informations[-1]["start_time"]
          time_informations[-1]["duration_time"] = duration_time
          measure_num = int(duration_time/time_informations[-1]["ticks_per_measure"])
          time_informations[-1]["measure_num"] = measure_num
          new_time_information["start_measure_id"] = time_informations[-1]["start_measure_id"] + measure_num
          time_informations.append(new_time_information)
    duration_time = m["start_time"] - time_informations[-1]["start_time"]
    time_informations[-1]["duration_time"] = duration_time
    measure_num = int(duration_time/time_informations[-1]["ticks_per_measure"])
    time_informations[-1]["measure_num"] = measure_num
      
    return time_informations

time_informations = MIDI2MusicXML._get_time_informations(messages)
for t in time_informations:
  print(t)
{'numerator': 4, 'denominator': 4, 'ticks_per_beat': 480, 'start_time': 0, 'tempo': 600000, 'notated_32nd_notes_per_beat': 8, 'ticks_per_measure': 1920, 'start_measure_id': 0, 'division_note': 32, 'divisions_per_measure': 32, 'ticks_per_division': 60, 'duration_time': 1920, 'measure_num': 1}
{'numerator': 4, 'denominator': 4, 'ticks_per_beat': 480, 'start_time': 1920, 'tempo': 461538, 'notated_32nd_notes_per_beat': 8, 'ticks_per_measure': 1920, 'start_measure_id': 1, 'division_note': 32, 'divisions_per_measure': 32, 'ticks_per_division': 60, 'duration_time': 272560, 'measure_num': 141}

time_informationの各キー値の意味は以下のとおりです。

  • type=set_tempoから取得される情報
    • tempo: 4分音符の長さ(マイクロ秒)
  • type=time_signatureから取得される情報
    • numerator: 拍子情報の分子。3/4拍子だとしたら3。
    • denominator: 拍子情報の分母です。3/4拍子だとしたら4。
    • notated_32nd_notes_per_beat: 1beatにはいる32分音符の個数。1beatが何音符であるかを特定して、tick単位はマイクロ秒単位に変換するために必要。例えばこの値が8だと、1beatは32/8=4分音符だとわかる。
  • その他から取得する情報
    • ticks_per_beat: 1拍子(beat)にMIDI内の時間単位(tick)の個数。MIDIファイル全体で1回定義されており、だいたい480。mido.MidiFileの属性として格納されている。
    • start_time: time_informationの開始時間。type = set_tempoまたはtime_signatureのstart_time。
  • 計算・独自定義する情報
    • ticks_per_measure: 1小節あたりのtick数。計算式はint(ticks_per_beat / notated_32nd_notes_per_beat * 32 / denominator * numerator)。言葉にすると、1beatあたりのtick数を1beatあたりの32分音符の個数で割って、32をかけると、1分音符の長さになる。1小節の長さは1分音符がnumerator/denominator個(たとえば3/4拍子なら1小節は1分音符3/4個分の長さ)なので、それをかけている。
    • start_measure_id: そのtime_inforamtionが何小節目から始まるかのindex。小節の途中でtime_informationが更新されることはないとはいいきれないのだが、今回は考慮しない。
    • division_note: musicxmlへの変換を想定した音符の最小単位(division)の定義。今回は32分音符を最小単位として32とする。
    • divisions_per_measure: 1小節あたりのdivision数。計算式はint(division_note / denominator * numerator)。考え方は、小節の長さは1分音符のnumerator/denominator倍なので、1分音符に入るdivisionの個数(division_note)をそれに乗じればよい。
    • ticks_per_division: 1divisionあたりのtick数。計算式はint(ticks_per_beat / notated_32nd_notes_per_beat * 32 / division_note)。考え方は、1beatあたりのtick数を1beatあたりの32分音符の個数で割ると32分音符あたりのtick数になる。それに32をかけると1分音符あたりのtick数になる。それをdivision_noteで割れば、divisionあたりのtick数になる。

音符を小節に分ける

MIDIには小節の概念がありませんが、MusicXMLでは必要なため、便宜的に導入します。

空の小節の作成

まず、time_informationに基づいて小節の長さ(tick)を定義し、小節数等を計算して、小節のリストを作ります。

  # notesが空の状態のmeasureのリストを取得する
  @staticmethod
  def _get_measures(time_informations):
    measures: List[Measure] = []
    for t in time_informations:
      for i in range(t["measure_num"]):
        measure_id = t["start_measure_id"] + i
        measure: Measure = {
          "measure_id": measure_id
          , "start_time": t["start_time"] + i * t["ticks_per_measure"]
          , "time_information": t
        }
        measures.append(measure)
    return measures

実行してみます。

measures = MIDI2MusicXML._get_measures(time_informations)
for m in measures[:10]:
  # みやすさのため、time_information以外を出力
  print({k:v for k,v in m.items() if k != "time_information"})
{'measure_id': 0, 'start_time': 0}
{'measure_id': 1, 'start_time': 1920}
{'measure_id': 2, 'start_time': 3840}
{'measure_id': 3, 'start_time': 5760}
{'measure_id': 4, 'start_time': 7680}
{'measure_id': 5, 'start_time': 9600}
{'measure_id': 6, 'start_time': 11520}
{'measure_id': 7, 'start_time': 13440}
{'measure_id': 8, 'start_time': 15360}
{'measure_id': 9, 'start_time': 17280}

音符の分割

小節の開始/終了時間に基づいて音符を分割します。
まずは音符の開始時間のみに基づいて小節位置を決定します。

  @staticmethod
  def _split_notes_by_measure(notes, measure_end_times, *
                              , start_key = "start_time"
                            ):
    note_measures = [[] for _ in measure_end_times]
    measure_id = 0
    for note in notes:
      if measure_id == len(measure_end_times) - 1:
        note_measures[measure_id].append(note)
      else:
        while note[start_key] >= measure_end_times[measure_id]:
          measure_id += 1
        note_measures[measure_id].append(note)        
    return note_measures

上記関数はnoteのリストと、各小節の終了時間のリストを引数にとります。
noteのstart_timeが小節のend_timeを超えないあいだ、その小節にnoteが含まれるとみなし、超えたら小節番号を増やす、という処理を実施しています。

関数を実行してみます。

measure_end_times = [m["start_time"]+m["time_information"]["ticks_per_measure"] for m in measures]
note_measures = MIDI2MusicXML._split_notes_by_measure(notes, measure_end_times)
for notes in note_measures[:5]:
  print(notes)
[]
[]
[{'type': 'note_on', 'start_time': 5280, 'duration_time': 238, 'note': 79}, {'type': 'note_on', 'start_time': 5520, 'duration_time': 238, 'note': 82}]
[{'type': 'note_on', 'start_time': 5760, 'duration_time': 358, 'note': 84}, {'type': 'note_on', 'start_time': 6120, 'duration_time': 358, 'note': 80}, {'type': 'note_on', 'start_time': 6480, 'duration_time': 238, 'note': 79}, {'type': 'note_on', 'start_time': 6720, 'duration_time': 238, 'note': 77}, {'type': 'note_on', 'start_time': 6960, 'duration_time': 238, 'note': 75}, {'type': 'note_on', 'start_time': 7200, 'duration_time': 238, 'note': 77}, {'type': 'note_on', 'start_time': 7440, 'duration_time': 238, 'note': 84}]
[{'type': 'note_on', 'start_time': 7680, 'duration_time': 238, 'note': 82}, {'type': 'note_on', 'start_time': 7920, 'duration_time': 118, 'note': 84}, {'type': 'note_on', 'start_time': 8040, 'duration_time': 238, 'note': 79}, {'type': 'note_on', 'start_time': 8280, 'duration_time': 358, 'note': 77}, {'type': 'note_on', 'start_time': 8640, 'duration_time': 838, 'note': 75}]

noteが小節の単位でリスト化されていることがわかります。

タイの処理

先までの処理では小節をまたぐ音符が存在しています。小節をまたぐ音符はタイで繋ぐ必要があるので、音符を小節の切れ目で分割した上で、タイのフラグを追加します。

  @staticmethod
  def _add_tie(note_measures: List[List[Note]], measure_end_times: List[int], *
                                  , start_key = "start_time"
                                  , duration_key = "duration_time"
                                  , tie_type_key = "tie_type"
                                  , stop_type = "stop"
                                  , start_type = "start"
                                  ) -> List[List[Note]]:
    note_measures_with_tie: List[List[Note]] = [[] for _ in measure_end_times]
    for i, (notes, measure_end_time) in enumerate(zip(note_measures, measure_end_times)):
      for j, note in enumerate(notes):
        if j != len(notes) - 1:
          note_measures_with_tie[i].append(note)
          continue
        # 最後の音符がmeasureをまたぐとき
        if note[start_key] + note[duration_key] > measure_end_time:
          last_note = copy.deepcopy(note)
          last_note[duration_key] = measure_end_time - note[start_key]
          last_note[tie_type_key] = last_note.get(tie_type_key, []) + [start_type]
          note_measures_with_tie[i].append(last_note)

          rest_note = copy.deepcopy(note)
          rest_note[start_key] = measure_end_time
          rest_note[duration_key] = note[start_key] + note[duration_key] - measure_end_time
          rest_note[tie_type_key] = [stop_type]
          note_measures_with_tie[i+1].append(rest_note)
        # またがないときは単に追加
        else:
          note_measures_with_tie[i].append(note)
    return note_measures_with_tie
# タイの情報を追加
measure_end_times = [(m["start_time"]+m["time_information"]["ticks_per_measure"]) for m in measures]
note_measures = MIDI2MusicXML._add_tie(note_measures, measure_end_times)
for notes in note_measures:
  for note in notes:
    if "tie_type" in note:
      print(notes)
      break
[{'type': 'note_on', 'start_time': 15360, 'duration_time': 238, 'note': 82}, {'type': 'note_on', 'start_time': 15600, 'duration_time': 238, 'note': 84}, {'type': 'note_on', 'start_time': 15840, 'duration_time': 238, 'note': 86}, {'type': 'note_on', 'start_time': 16080, 'duration_time': 478, 'note': 87}, {'type': 'note_on', 'start_time': 16560, 'duration_time': 238, 'note': 79}, {'type': 'note_on', 'start_time': 16800, 'duration_time': 238, 'note': 77}, {'type': 'note_on', 'start_time': 17040, 'duration_time': 240, 'note': 75, 'tie_type': ['start']}]
[{'type': 'note_on', 'start_time': 17280, 'duration_time': 1918, 'note': 75, 'tie_type': ['stop']}]
...

なお、tie_typeの型がリストになっているのは、タイが連続する場合に対応するためです。tie_typeが["stop", "start"]となっていれば、その音符はtieの終点であり、始点であることを意味します。

拍子の取得

MIDIの時間単位(tick)をMusicXMLで使うdivision_note(division)の単位に直します。
division_noteは楽譜に登場する最小の音符の長さのことです。今回は32分音符を最小長さとします(すでにtime_informationの要素、division_noteに32を指定してあります)。厳密には、32分音符より短い音符がMIDI中に登場しないことを確認する処理が必要ですが、今回は省略しています。

必要な情報は、各音符の、小節はじめからの経過時間と持続時間(division_note単位)です。このためにはdivision_note1つあたりのtick数が必要であり、これはtime_informationsの生成時に取得してあります。

  @staticmethod
  def _add_division(note_measures: List[List[Note]], measure_start_times: List[int], measure_ticks_per_divisions: List[int], *
                                    , start_time_key = "start_time"
                                    , start_division_key = "start_division"
                                    , duration_time_key = "duration_time"
                                    , duraiton_division_key = "duration_division"
                                      ) -> List[List[Note]]:
    note_measures_with_division = [[] for _ in measure_start_times]
    #print(note_measures)
    for i, (notes, measure_start_time, measure_ticks_per_division) in enumerate(zip(note_measures, measure_start_times, measure_ticks_per_divisions)):
      tick2division = lambda x: round(x/measure_ticks_per_division)
      for note in notes:
        note[start_division_key] = tick2division(note[start_time_key] - measure_start_time)
        note[duraiton_division_key] = tick2division(note[duration_time_key])
        #print(note["duration_time"],note["duration_division"], measure_ticks_per_division)
        note_measures_with_division[i].append(note)
    return note_measures_with_division
# noteにmeasure起点のdivisionと持続divisionを追加
measure_start_times = [m["start_time"] for m in measures]
measure_ticks_per_divisions = [m["time_information"]["ticks_per_division"] for m in measures]
note_measures = MIDI2MusicXML._add_division(note_measures, measure_start_times, measure_ticks_per_divisions)
for notes in note_measures[:3]:
  print(notes)
[]
[]
[{'type': 'note_on', 'start_time': 5280, 'duration_time': 238, 'note': 79, 'start_division': 24, 'duration_division': 4}, {'type': 'note_on', 'start_time': 5520, 'duration_time': 238, 'note': 82, 'start_division': 28, 'duration_division': 4}]

コードについて補足します。
実際のデータをみると、ticks_per_divisionが60に対して、duration_timeが238と倍数になっていません。duration_timeはMIDIとして自然に聞こえるようにするために、少し短めに設定されているのだと思います。そこで、round関数で四捨五入をしています。単にintでまるめると切り捨てになるので注意してください。

休符の挿入

いよいよ最後です。
MIDIには休符の情報がありませんが、MusicXMLでは必要になるので、音符のstart_divisionとduration_divisionから、音符のなっていない時間帯をもとめ、休符を挿入します。

  @staticmethod
  def _add_rest(notes: List[Note], divisions_per_measure: List[int], *
                          , start_key = "start_division"
                          , type_key = "type"
                          , rest_type = "rest"
                          , duration_key = "duration_division"
                          ):
    last_division = 0
    measure_with_rest = []
    for note in notes:
      diff_division = note[start_key] - last_division
      if diff_division > 0:
        rest = {}
        rest[type_key] = rest_type
        rest[duration_key] = diff_division
        rest[start_key] = last_division
        measure_with_rest.append(rest)
      measure_with_rest.append(note)
      last_division = note[start_key] + note[duration_key]
    if last_division < divisions_per_measure:
      rest = {}
      rest[type_key] = rest_type
      rest[duration_key] = divisions_per_measure - last_division
      rest[start_key] = last_division
      measure_with_rest.append(rest)

    return measure_with_rest

# 休符の追加
divisions_per_measures = [m["time_information"]["divisions_per_measure"] for m in measures]
note_measures = [MIDI2MusicXML._add_rest(notes, divisions_per_measure) for notes, divisions_per_measure in zip(note_measures, divisions_per_measures)]
for notes in note_measures[:5]:
  print(notes)
[{'type': 'rest', 'duration_division': 32, 'start_division': 0}]
[{'type': 'rest', 'duration_division': 32, 'start_division': 0}]
[{'type': 'rest', 'duration_division': 24, 'start_division': 0}, {'type': 'note_on', 'start_time': 5280, 'duration_time': 238, 'note': 79, 'start_division': 24, 'duration_division': 4}, {'type': 'note_on', 'start_time': 5520, 'duration_time': 238, 'note': 82, 'start_division': 28, 'duration_division': 4}]
[{'type': 'note_on', 'start_time': 5760, 'duration_time': 358, 'note': 84, 'start_division': 0, 'duration_division': 6}, {'type': 'note_on', 'start_time': 6120, 'duration_time': 358, 'note': 80, 'start_division': 6, 'duration_division': 6}, {'type': 'note_on', 'start_time': 6480, 'duration_time': 238, 'note': 79, 'start_division': 12, 'duration_division': 4}, {'type': 'note_on', 'start_time': 6720, 'duration_time': 238, 'note': 77, 'start_division': 16, 'duration_division': 4}, {'type': 'note_on', 'start_time': 6960, 'duration_time': 238, 'note': 75, 'start_division': 20, 'duration_division': 4}, {'type': 'note_on', 'start_time': 7200, 'duration_time': 238, 'note': 77, 'start_division': 24, 'duration_division': 4}, {'type': 'note_on', 'start_time': 7440, 'duration_time': 238, 'note': 84, 'start_division': 28, 'duration_division': 4}]
[{'type': 'note_on', 'start_time': 7680, 'duration_time': 238, 'note': 82, 'start_division': 0, 'duration_division': 4}, {'type': 'note_on', 'start_time': 7920, 'duration_time': 118, 'note': 84, 'start_division': 4, 'duration_division': 2}, {'type': 'note_on', 'start_time': 8040, 'duration_time': 238, 'note': 79, 'start_division': 6, 'duration_division': 4}, {'type': 'note_on', 'start_time': 8280, 'duration_time': 358, 'note': 77, 'start_division': 10, 'duration_division': 6}, {'type': 'note_on', 'start_time': 8640, 'duration_time': 838, 'note': 75, 'start_division': 16, 'duration_division': 14}, {'type': 'rest', 'duration_division': 2, 'start_division': 30}]

コードについて補足します。
休符の挿入時は、小節をまたぐ処理を実施しないので、小節単位で処理できるように、List[Note]を引数としています。
また休符にはtype=restを指定して、音符(type=note_on)と区別しています。
tickベースの数値(start_time, duration_time)は使う予定がないので入れていません。start_division, duraiton_divisionのみを定義しています。
なお、タイ、division_note単位の計算、休符の挿入の順番は固定ではありませんが、duration_timeが中途半端な長さになっている関係上、本記事の順序が計算しやすいと思います。

その3で使うために保存しておきます。

with open("yorunikakeru_notes.json", "w") as f:
  json.dump(note_measrues, f, indent=2, ensure_ascii=False)

おわりに

ここまででMIDIの音符情報からMusicXMLで求められる情報を抽出することができました。
ただある程度理想的なMIDIを想定して実装しているので、以下のような場合にも対処できるようにすることは今後の課題です。

  • time_informationの変更(set_tempoやtime_signature)が小節の途中で行われた場合の処理。今はそれらのmessageが小節の頭にくることを前提としてしまっている。
  • 小節の頭の検出。現状はtime_informationの変更時間を小節の始まりとしている。MusicXMLを作るだけなら大きな問題はないような気もするが、例えば、全部裏拍になってしまうとかもあり得る。別の方法で小節の頭を検出できると楽譜としてより自然にできるかも。
  • division_noteの正確な抽出。32分音符を単位として良いかの保証は特にない。
  • 長さの中途半端な小節の処理。今はそのようなものが登場しないことを前提としているが、途中で小節が切れる場合やむしろ微妙に長い場合、どうするか。フェルマータなど、小節の長さを変える音楽記号もあり得る。
  • 型の定義、適用など。割と適当なので、いずれもう少し整理したいです。
  • テストコード。かけていないので、そのうち書きたいです。
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1