Morikatron Engineer Blog

モリカトロン開発者ブログ

Pythonと音楽と...(3)MIDIファイルの再生

こんにちは、モリカトロンのチーフエンジニア松原です。 連載第3回めの今回は、MIDIファイルの読み込み、解析、音声波形の生成、合成、再生まで実装しました。

MIDIについて

概要

MIDIは Musical Instrument Digital Interface の頭文字。すなわち電子楽器間(シンセサイザーやシーケンサ、PCなど)で演奏データをやりとりするための規格です。そのMIDI規格の演奏データをファイルに保存したものがスタンダードMIDIファイル(略してMIDIファイルとかSMF)と呼ばれるフォーマットです。

MIDIの仕様書を読みたい方は

現在MIDI 1.0規格の仕様書が一般社団法人 音楽電子事業協会のHPのダウンロードページからダウンロードできるようになっています。MIDI規格を隅々までキッチリと定義した資料ですので、MIDIデータをリアルタイムでやりとりしたい人や、MIDIファイルを生で扱ってみたい方には必須の資料です。が、Pythonでちょっといじってみようかな程度であれば詳細なデータ構造まで知る必要はまずありませんのでご安心ください。

MIDIファイルの中身

MIDIファイルのフォーマットにはSMF0、SMF1、SMF2の3種類ありますが、普通はSMF1(1ファイルに複数トラックを保存できる)を使います。
MIDIファイルには全体情報(フォーマットの種類0/1/2、トラックの数、時間の分解能を表す値)とトラックの情報いろいろ(イベント情報や各種制御情報など、時系列に発生するもの)が詰まっています。これを読み込んで解析することで、音楽が再生できるって寸法です。
MIDIファイルの中で重要なのが、音符と1:1に対応するノートというデータです。楽曲中の全ての音符について、ノートが書き込まれています。ノートは「いつから、いつまで、どのくらいの音量で、どの音程を出す」という情報なので、これを正確に再現することで、MIDIファイルに書かれた楽曲を演奏することが可能です。

MIDIファイルを入手するには

MIDIファイルを手に入れるには、いちばん面白いのは自分で作ることです。楽器ができるひとはMIDI対応の電子楽器を演奏して、シーケンサーソフトで生MIDI録音することもできますし、音楽ソフトで楽譜を編集して保存も可能です。とはいえ、とりあえず手軽に入手したい場合はインターネットで検索すると私的利用の範囲内でダウンロード可能なMIDIファイルなどが見つかりますので、そのあたりで入手してください。

PythonでMIDIを扱うには

むかし

むかし弊社社長 森川といっしょにNintendo DS用音楽ソフト「つくって うたう さるバンド」を作った時、音楽家さんが制作した大量のMIDIファイルを取り込んで大量増殖させるコードをC言語で書いたことがありますが、MIDIファイルの変態的な仕様にはたいへん悩まされました。当時はMIDIの仕様書ダウンロードなどなく、MIDIの仕様を書いた市販の本を舐めるように読みながら、トライアンドエラーで作っていった記憶があります。MIDIのせいで白髪が増えました。

いま

しかし、2020年の今、PythonでMIDIを扱うなら、そんな苦労はありません。すでに素晴らしいMIDIモジュールがいろいろとあり、選択に迷うほどです。この記事では、数あるMIDIモジュールの中から、簡単に使えてメッセージの取りこぼしのなさそうな Mido を使うことにしました。Midoはたいへん有能で、ハチャメチャに複雑なMIDIファイルの中身を解析して、ノート情報もテンポ情報もすべてリストでアクセスできるよう整理整頓してくれます。控えめに言っても最高というやつです。それでは実際のコードを見てみましょう。

sample5_play_midi.py

MIDIファイルを読み込んでがんばって再生するプログラムです。 https://github.com/morikatron/snippet/blob/master/python_and_music/sample5_play_midi.py

"""
sample5_play_midi.py

© Morikatron Inc. 2020
written by matsubara@morikatron.co.jp

MIDIファイルを読み込んでがんばって再生するプログラム
"""
import math
import sys
import os

import numpy as np  # install : conda install numpy
import pyaudio  # install : conda install pyaudio
import mido  # install : pip install mido

# サンプリングレートを定義
SAMPLE_RATE = 44100

# MIDIの1ノートを表現するリストの要素を定義しておく
IX_ON_MSEC = 0  # int note onの時間 単位はミリ秒
IX_OFF_MSEC = 1  # int note offの時間 単位はミリ秒
IX_NOTE_NUMBER = 2  # int ノート番号
IX_VELOCITY = 3  # float 音量 範囲は0-1.0
IX_DULATION = 4  # float 音の長さ 単位は秒


# 指定ノート番号のサイン波を、指定秒数生成してnumpy配列で返す関数
def notenumber2wave(notenumber: int, duration: float, volume: float) -> np.array:
    # MIDIのノート番号を周波数に変換
    freq = 440.0 * 2 ** ((notenumber - 69) / 12)
    # 指定周波数のサイン波を指定秒数分生成
    samples = np.sin(np.arange(int(duration * SAMPLE_RATE)) * freq * np.pi * 2 / SAMPLE_RATE) * volume
    # 波形の頭とお尻を最大100サンプル(約0.002秒)をフェード処理する(つなぎ目のプチノイズ軽減のため)
    fade_len = min(100, samples.size)  # フェード処理するサンプル数
    slope = (np.arange(fade_len) - 1) / fade_len  # フェードインのスロープ計算
    samples[:fade_len] = samples[:fade_len] * slope  # サンプル先頭とスロープを掛けてフェードイン
    slope = ((fade_len - 1) - np.arange(fade_len)) / fade_len  # フェードアウトのスロープ計算
    samples[-fade_len:] = samples[-fade_len:] * slope  # サンプル末尾とスロープを掛けてフェードアウト
    return samples


# n個の波形の長さを揃えて縦に積む関数
def padding_and_stack(inputs: list) -> np.array:
    # 配列の総数が0なら、返すものがない
    if len(inputs) < 1:
        return None
    # 配列の総数が1なら、その1個を返す
    if len(inputs) == 1:
        return inputs[0]
    # 配列の最大要素数を調べる
    maxlen = 0
    for x in inputs:
        if maxlen < len(x):
            maxlen = len(x)
    # 最大数に揃える(うしろに0を加える)
    for i, x in enumerate(inputs):
        if len(x) < maxlen:
            inputs[i] = np.pad(x, (0, maxlen - len(x)))
    # 縦に積む
    return np.vstack(inputs)


# 渡された波形(np.array)を再生する
def play_wave(wave) -> None:
    # PyAudio開始
    p = pyaudio.PyAudio()

    # ストリームを開く
    stream = p.open(format=pyaudio.paFloat32,
                    channels=1,
                    rate=SAMPLE_RATE,
                    frames_per_buffer=1024,
                    output=True)

    # 再生
    stream.write(wave.astype(np.float32).tostring())

    # ストリームを閉じる
    stream.close()

    # PyAudio終了
    p.terminate()


# MIDIファイルの中のメッセージをすべてプリント
def print_midi(file_path: str) -> None:
    print("----------------- PRINT MIDI -----------------")
    file = mido.MidiFile(file_path)
    print(file)
    for track in file.tracks:
        print("-----------------", track)
        for message in track:
            print(message)
    print("----------------------------------------------")


# 1トラック分の notes[note[],note[],...] を波形に変換して返す
def notes2track(notes: list) -> np.array:
    # まずトラックの長さを調査(いちばん遅いnote off)
    track_msec = 0
    for note in notes:
        if track_msec < note[IX_OFF_MSEC]:
            track_msec = note[IX_OFF_MSEC]
    # まずトラックの長さの無音波形(ベース配列)を作る
    track_base = np.zeros(int((track_msec / 1000.0) * SAMPLE_RATE))
    # 次に、ノートの波形を生成し、ベース配列の上に重ねていく
    for note in notes:
        if note[IX_DULATION] > 0:  # サンプルが存在するくらい長い場合に限って処理する
            wave = notenumber2wave(note[IX_NOTE_NUMBER], note[IX_DULATION], note[IX_VELOCITY])
            fromix = int((note[IX_ON_MSEC] / 1000.0) * SAMPLE_RATE)
            toix = fromix + wave.size
            stacked = np.vstack((wave, track_base[fromix:toix]))
            newwave = stacked.mean(axis=0)
            track_base[fromix:toix] = newwave
    return track_base


# MIDIファイルの全トラック波形を合成した1つの波形を返す
def midi2wave(file_path: str) -> np.array:
    tempo = 500000.0
    file = mido.MidiFile(file_path)
    ticks_per_beat = file.ticks_per_beat
    abs_time_tick_msec = tempo / ticks_per_beat / 1000.0

    tracks = []
    # Search tracks
    for track in file.tracks:
        now = 0  # 現在の時刻(msec)を保持
        notes = []  # ノート情報の配列
        for event in track:
            now = now + event.time * abs_time_tick_msec
            if event.type == 'set_tempo':
                tempo = event.tempo
                abs_time_tick_msec = tempo / ticks_per_beat / 1000.0
                # print("BPM = ", 60000000.0 / tempo)
            elif event.type == 'note_on' and event.channel == 9:
                # 打楽器を無視
                pass
            elif event.type == 'note_off' or (event.type == 'note_on' and event.velocity == 0):
                # ノートオフを処理
                for note in notes:
                    if (note[IX_OFF_MSEC] == 0) and (note[IX_NOTE_NUMBER] == event.note):
                        note[IX_OFF_MSEC] = now
                        note[IX_DULATION] = (note[IX_OFF_MSEC] - note[IX_ON_MSEC]) / 1000.0
                        note[IX_VELOCITY] = note[IX_VELOCITY] / 127.0
            elif event.type == 'note_on':
                # ノートオンを登録
                notes.append([math.floor(now), 0, event.note, event.velocity, 0])
        if len(notes) > 0:  # このトラックに発音すべきノートがあれば
            print("midi2wave: converting track #", len(tracks), "...")
            tracks.append(notes2track(notes))  # トラックの波形を作る

    # 1つの波形に合成(個々のサンプルの平均を取る)=ボリューム1:1:1のミキシング
    print("midi2wave: mixising", len(tracks), "tracks ...")
    mixed_wave = padding_and_stack(tracks).mean(axis=0)
    print("midi2wave: finish!")
    return mixed_wave


# 指定されたMIDIファイルを波形に変換して再生する
def play_midi(file_path: str) -> None:
    print_midi(file_path)  # MIDIファイルの中身を全部プリント(デバッグのため)
    wave = midi2wave(file_path)  # MIDIファイルを波形に変換
    play_wave(wave)  # 波形を再生


if __name__ == '__main__':
    argv = sys.argv
    argc: int = len(argv)
    if argc == 2:  # 1個目のパラメータはMIDIファイルのパスとする
        play_midi(argv[1])
    else:
        # 引数がないときは本プログラムの使い方を示す
        print("Usage: python sample5_play_midi FILE.mid ")
        # marching.midがあればそれを変換する(簡単な動作サンプルとして)
        if os.path.exists("marching.mid"):
            play_midi("marching.mid")

ブログ上で、プログラムに行番号を表示するようCSSとJavaScriptを仕込んだのですが、空白行が潰れてしまってちょっと見にくいかもしれませんが、ご容赦ください。 また、だんだん長くなってきたので読みにくいかと思います。よろしければGithubからお手元のPCにダウンロードしてごらんください。

それでは、コードの下から上に向かって、簡単に解説して行きます。

  • 141-151行目
    本コードがPythonから呼び出された時の処理です。本プログラムはコマンドラインから以下のように呼び出される想定です。FILE.midはMIDIファイルのパス。これが指定されていれば、play_midi関数(後述)を呼び出します。パスが指定されていない場合はUsage:を表示して終わってもいいのですが、それだと少し寂しいので、サンプルのMIDIファイルを処理(読み込んで再生)するようにしています。
$ python sample5_play_midi FILE.mid 
  • 137-140行目 play_midi関数
    MIDIファイルのパスを受け取って、読み込み、再生を行う関数です。
    138行目print_midi関数(後述)の呼び出しは必須ではないのですが、読み込んだMIDIデータをすべてコンソールに出力してくれます(MIDIファイルの中身がどんなふうになっているのか興味がありますよね?)
    139行目midi2wave関数(後述)は、MIDIファイルのパスを渡すと波形を生成して返してくれます。
    140行目play_wave関数(後述)にこの波形データを渡すことで再生が実行されます。

  • 99-135行目 midi2wave関数
    101行目で(Mido)https://mido.readthedocs.io/en/stable/を使ってMIDIファイルを読み込みます。たったの1行です(昔の苦労はなんだったのか)。
    102-103行目と111-113行目は、曲のテンポ情報を取得して、その時点以降のノートの再生時間に反映するための処理。MIDIは曲中のテンポ変更にも対応していて(モチロンですね)、ノートとノートの間にテンポ変更メッセージ(Midoのset_tempoイベント)が入ることがあります。それが111-113行目です。ちなみに本プログラム内部では、ノートの開始終了時刻をすべてミリ秒に換算して記録するようにしてあります。
    MIDIファイルには複数のトラックが書いてありますので、そのトラックの数だけ106-130行の間をループして、トラック内のノートを分析します。
    115行目は打楽器(ドラムとか)を無視するための行です。本プログラムではドラム(ハイハットとかスネアとかバスドラとか……)のシミュレーションはサポートしませんので、ドラムトラックのノートは無視します。無視しない場合それはそれで面白いことになりますので、試したい方はぜひどうぞ。ドラムも再生したい場合は、ハイハットとかスネアとかの音色データ(サンプリングサウンド)を用意しておいて、タイミングにあわせてそれを再生すればOK。元気な人はやってみましょう。
    125-126行目はノートオンの処理。ノートの再生が始まるときに、開始時刻/音程/音量をリストnotesに登録しています(このとき終了時刻は0にしてあります)。
    118-124行目はノートオフの処理。リストnotesに登録されている終了時刻0のノートを探し、そこに終了時刻を書き込みます。ノートオフはnote_offメッセージがくる場合と、音量が0のノートオンメッセージがくる場合の2パターンあるので、118行目のような書き方になっています。
    128-130行目は、1トラック分のノート情報が収集されたリストnotesをnotes2track関数(後述)に渡し、変換された波形を受け取ってリストtracksに追加しています。
    131-134行目は、全トラックの波形を1本の波形にミキシングし、
    135行目で呼び出し元に波形を返してあげます。

  • 80-97行目 notes2track関数 1トラック分のノートを、1本の波形に合成する関数です。
    サクソフォンのようにモノフォニック(同時に1音しか鳴らない)の楽器用のトラックならば、音符の発音時間が重なることがないので簡単に作れますが、ピアノのようにポリフォニック(同時に複数音が鳴る)の楽器を考えると、発音時間が重なっている場合でもうまく合成する必要があります。時間的に重なった波形を合成するには、前回連載でやったように、サンプル間の平均を取ればいいので、まずはじめにトラックの演奏時間を計測し(82-85行目)、np.zero関数でトラックの長さだけ0音量のサンプル配列track_baseを作成します。
    次に全ノートについてnotenumber2wave関数(後述)で波形を生成(91行目)し、サンプル間の平均を取ってtrack_baseへ合成します(92-96行目)。
    最後に完成したtrack_baseを呼び出し元へ返します。

  • 70-78行目 print_midi関数 Midoの機能を使って、MIDIファイル中の全情報(ファイルの情報、各トラックの情報、全メッセージ情報)をコンソールにプリントします。

  • 54-68行目 play_wave関数 PyAudioを使って波形を再生します(前回〜前々回の記事で説明しています)。

  • 35-52行目 padding_and_stack関数 複数のトラックを1本にミックスします(前回の記事で説明しています)。

  • 22-33行目 notenumber2wave関数 1つの音符の波形を生成します。前回との違いは、音量パラメータvolumeがあることです。np.sinで生成したサイン波に*volumeとやるだけで、各サンプルに音量を掛けてくれます(Numpy配列便利)。

以上のようなプログラムで、MIDIファイルの中の(ドラムトラック以外の)音符をすべてサイン波で合奏することができます。ぜひいろいろなMIDIファイルを再生してみてください。興味があれば、ドラムトラックをサンプリングサウンドで再生してみるとか、サイン波のかわりにサンプリングサウンドを使うことで、よりリアルな楽器をシミュレーションしてみるとか、エコーのような音響効果を足してみるなど、いろいろな遊びができますので、がんばってみてください。

marching.midについて

サンプルで再生しているmarching.mid(Githubのリポジトリにもあげています)は、ウロチョロスでtoioに合唱させるためのMIDIファイルで、音楽家の永田さんに作っていただきました。ありがとうございました。

まとめ

上記のようなPythonのスクリプトを書けば、ごく簡単にMIDIのテンポ、トラック、ノートを分解整理できることがわかったと思います。そうなると、他のプラットフォーム……たとえば他の言語用にデータを変換したり、ゲーム機や、エッジデバイス、ロボットなどに転送して歌わせたり、といったこともできるようになります。つまりMIDIファイルをインタフェースとすることで、たとえばプロの作曲家が制作した曲を、自作プログラム・自作ロボットプログラムに搭載できるようになるわけです。これは本当に素晴らしいことです。
というわけで、次回は、読み込んだMIDIの楽譜を、toioのCubeが発音できるデータに変換し、Cube制御プログラムにペーストして複数のCubeに合唱させてみたいと思います。たぶんtoio側の制御プログラムも含めて公開できるようになると思いますので、首を長くしてお待ちくださいませー!