歌詞付きMIDIをMusicXMLに変換 その５：Webアプリ化

# 概要
前回まで作成した関数をブラウザから実行できるようにします。
下図のようなUIで、MIDIファイル（XF仕様）をアップロードしたら、MusicXMLをダウンロードできるようにすることを目指します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259353/02ca8678-c137-8762-6a00-1e5280aac61d.png)

シリーズ一覧
[歌詞付きMIDIをMusicXMLに変換 リンクまとめ](https://qiita.com/shimajiroxyz/items/a675075b44cf4c0a7487)

# 方針
Flaskで作っていきます。
参考にさせていただいた記事は末尾にまとめます。

# フォルダ構成
以下のようにします。
server.pyとtepmlatesフォルダが今回新規に作成したものです。XFMidiFile.pyは「その１」、MIDI2MusicXML.py、template.musicxmlは「その４」を作成したものです。

```
./
├── MIDI2MusicXML.py
├── XFMidiFile.py
├── template.musicxml
├── server.py
└── templates
　　　　├── flask_api_index.html
　　　　└── layout.html
```

# インストール

```
pip install flask
```

# server.py

```Python
from flask import Flask, render_template, request, redirect, url_for, send_from_directory, make_response
from midi2musicxml import MIDI2MusicXML
from pathlib import Path
from werkzeug.utils import secure_filename

MUSICXML_MIMETYPE = "application/vnd.recordare.musicxml"

app = Flask(__name__)


@app.route('/')
def index():
    return render_template('./flask_api_index.html')

@app.route('/result', methods=['POST'])
def result():
    # submitした画像が存在したら処理する
    if request.files['midi']:
        stream = request.files["midi"].stream
        filename = request.files["midi"].filename
        filename = secure_filename(filename)
    
        # 画像の読み込み
        musicxml_string = MIDI2MusicXML.exec(stream
                                    , template_musicxml_path="template.musicxml"
                                    , save=False)

        if musicxml_string:
          response = make_response()

          response.data = musicxml_string

          downloadFileName = '{}_from_midi.musicxml'.format(Path(filename).stem)    
          response.headers['Content-Disposition'] = 'attachment; filename=' + downloadFileName

          response.mimetype = MUSICXML_MIMETYPE
          return response

if __name__ == '__main__':
    app.debug = True
    app.run(host='localhost', port=5000)
```

`stream = request.files["midi"].stream`で取得したファイルストリームを`MIDI2MusicXML.exec`に渡しています。MIDI2MusicXML側はファイルストリームを受け取れるように、「その４」から一部変更しています（後述）

また`make_response`で作成したmusicxmlをダウンロードさせるようにしています。

# MIDI2MusicXML.pyとXFMidiFile.pyの改修
MIDI2MusicXML.execはもともとファイルパス（str）を受け取る関数でしたが、Webアプリ化するにあたってファイルストリームを直接受け取れたほうがよいので、少し変更しています。

変更後のexec全体は以下です。

```Python:MIDI2MusicXML.py
  @classmethod
  def exec(self, filepath, *
                    , template_musicxml_path = "template.musicxml"
                    , outdir = None
                    , save=True):
    

    has_xfkm_chunk = XFMidiFile.has_xfkm_chunk(filepath)
    # もしxfkm_chunkがなければ解析不可
    if not has_xfkm_chunk:
      print("{} is not xf format".format(filepath))
      return
    # midiのタイプが0じゃなかったら解析不可とする処理を書きたい
    """todo"""

    # 楽曲情報を取得
    _, _, _, lang = XFMidiFile.get_xflyricinfo(filepath)
    # JPの場合、文字コードを指定
    if lang == "JP":
      if type(filepath) is str:
        xfmidi = XFMidiFile(filepath, charset="cp932")
      else:
        xfmidi = XFMidiFile(None, filepath, charset="cp932")
    else:
      if type(filepath) is str:
        xfmidi = XFMidiFile(filepath)
      else:
        xfmidi = XFMidiFile(None,filepath)
    self.xfmidi = xfmidi
    
    # trackをdictlistに変換
    lyrics = [v.dict() for v in xfmidi.xfkm]
    messages = [v.dict() for v in xfmidi.tracks[0]]
    # 開始時間を付与
    messages = self._add_start_time(messages)
    
    # 開始時間を付与
    lyrics = self._add_start_time(lyrics)

    # 楽曲情報を取得
    _, melody_channel, _, _ = XFMidiFile.get_xflyricinfo(filepath)
    melody_channel = int(melody_channel) - 1 # melody_channelのindexが１ずれている。ヤマハだけ？
    
    # melody_channelのnote_on, note_offだけを抽出
    note_ons = [(v["note"], v["start_time"]) for v in messages if v["type"] == "note_on" and v.get("channel", -1) == melody_channel]
    note_offs = [(v["note"], v["start_time"]) for v in messages if v["type"] == "note_off" and v.get("channel", -1) == melody_channel]
    duration_times = self._get_duration_times(note_ons, note_offs)

    notes: List[Note] = []
    for (pitch, start_time), duration_time in zip(note_ons, duration_times):
      note: Note = {
        "type": "note_on"
        , "start_time": start_time
        , "duration_time": duration_time
        , "note": pitch
      }
      notes.append(note)

    # time_informationを取得
    time_informations = self._get_time_informations(messages)
    # measureのリストを取得
    measures = self._get_measures(time_informations)
    
    # noteをmeasure単位にsplit
    measure_end_times = [m["start_time"]+m["time_information"]["ticks_per_measure"] for m in measures]
    note_measures = self._split_notes_by_measure(notes, measure_end_times)

    #print(note_measures)
    # タイの情報を追加
    measure_end_times = [(m["start_time"]+m["time_information"]["ticks_per_measure"]) for m in measures]
    note_measures = self._add_tie(note_measures, measure_end_times)

    # 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 = self._add_division(note_measures, measure_start_times, measure_ticks_per_divisions)

    # 休符の追加
    divisions_per_measures = [m["time_information"]["divisions_per_measure"] for m in measures]
    note_measures = [self._add_rest(notes, divisions_per_measure) for notes, divisions_per_measure in zip(note_measures, divisions_per_measures)]
    # start_time、歌詞をkey, valueとする辞書を作成
    """todo: start_timeのかぶりは本当にない？"""
    lyric_dict = {v["start_time"]: v["text"] for v in lyrics if v["type"] == "lyrics"}
  
    # noteと歌詞を対応付け
    """todo
    ・noteとlyricのタイムスタンプがずれているときの処理（今はnoteとlyricのタイムスタンプが厳密に一致している前提）
    """
    note_measures = [[self._add_lyric(note, lyric_dict) for note in notes] for notes in note_measures]
    # 「は」、「へ」を「わ」「え」になおす
    fixed_pronunciations = self._get_fixed_pronunciations(note_measures)
    for pronunciation, measure_id, note_id in fixed_pronunciations:
      note_measures[measure_id][note_id]["lyric_pronunciation"] = pronunciation

    # lyricが対応しないnoteの処理。直前にnoteがあれば長音、なければ「あ」とする
    """todo
    ・直前の文字が「ん」のときや、ないときも同じ処理で良い？
    """
    note_measures = self._add_rule_base_lyric(note_measures)
    #outputjson(note_measures, "note_measures_with_rule_base_lyric.json")

    # lyricが２文字の要素の処理。
    """todo
    ・durationの分割の最小単位を判定（たぶん32分音符まで？）
    """  

    # measuresのnoteをセット
    temp = []
    for notes, measure in zip(note_measures, measures):
      measure["notes"] = notes
      temp.append(measure)
    measures = temp
    
    self.note_measures = note_measures
    self.measures = measures

    musicxml_string = self._get_musicxml_string(measures, template_musicxml_path)
    if save:
      outdir = outdir or self._get_outdir(filepath)
      outfile = Path(outdir).joinpath("from_midi.musicxml")
      with open(outfile, "w") as f:
        f.write(musicxml_string)

    return musicxml_string
```

主な変更点は以下です。

* 引数にsaveを追加し、Falseのときはファイルを保存しないようにする。（WebアプリではMusicXMLをサーバ側で保存する必要がないため）
* filepathのタイプがstrとそれ以外のときで処理を分岐。strでないとき=ファイルストリームのときはXFMidiFileの第１引数をNone、第２引数にfilepathを渡すことで、ファイルストリームとして処理できる。XFMidiFileの継承元のmido.MidiFileがファイルストリームを受け取る機能をそのまま使っているだけ。


またXFMidiFileも以下を改修します。

```Python:XFMidiFile.py
    @staticmethod
    def has_xfkm_chunk(filepath):
        if type(filepath) is str:
            with open(filepath, "rb") as f:
                buf = f.read()
        else:
            start_pos = filepath.tell()
            buf = filepath.read()
            filepath.seek(start_pos)
        
        # XF Karaoke Message チャンクの取得
        chunk_type_bytes = b'XFKM'
        return chunk_type_bytes in buf

...

    @staticmethod
    def get_xflyricinfo(filepath):
        if type(filepath) is str:
            with open(filepath, "rb") as f:
                buf = f.read()
        else:
            start_pos = filepath.tell()
            buf = filepath.read()
            filepath.seek(start_pos)
        
        # XF Karaoke Message チャンクの取得
        chunk_type_bytes = b'XFKM'
        if chunk_type_bytes not in buf:
            return (None, None, None, None)

        index = buf.index(chunk_type_bytes)
        xfkm_bytes = buf[index:]
        information_header_index = xfkm_bytes.index(b'\xff\x07')
        information_header_data_length = xfkm_bytes[information_header_index+2] # FF 07 len textという構成
        information_header = xfkm_bytes[information_header_index: information_header_index+3+information_header_data_length]
        # 情報を取得（日本語はないはずなので文字コードは気にせずdecodeしてsplit）
        id, melody_channel, offset, lang = information_header[3:].decode().split(":")
        return id, melody_channel, offset, lang

```
どちらも、MIDI2MusicXML.execで使われる関数であるため、filepathがストリームの場合も処理できるように変更しています。



# flask_api_index.html
受け取るファイルのタイプをmidiにしています。

```html:flask_api_index.html
{% extends "layout.html" %}
{% block content %}

<form action="/result" method="post" enctype="multipart/form-data">
  <input type="file" name="midi" accept="audio/midi">
  <button type="submit">Submit</button>
</form>

{% endblock %}
```

# layout.html

```html:layout.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>API Sample</title>
  </head>
  <body>
    {% block content %}
    <!-- ここに内容 -->
    {% endblock %}
  </body>
</html>
```

# 実行

`python server.py`でサーバを起動後、`127.0.0.1:5000`にアクセスします（自環境(Mac)ではなぜかlocalhost:5000ではだめで、127.0.0.1だといけました）

適当なXF仕様のMIDIをアップロードして「Submit」を押下するとファイルがダウンロードされます。中身が正しそうなMusicXMLであれば成功です。

# おわりに
無事、ブラウザからMIDIファイルをMusicXMLに変換できるようになりました。
Flask、簡単でいいですね。

# 参考記事

* [Flaskで画像分類Webアプリを作成する(Mobile Net)](https://qiita.com/5zm/items/760000cf63b176be544c)
    * フォルダ構成とserver.py、flask_api_index.html、layout.htmlの中身はほぼそのまま使わせていただきました。
* [Flaskでファイルダウンロードを実現する３つの方法](https://qiita.com/5zm/items/760000cf63b176be544c)
    * make_responseを使ったダウンロード機能の実装の参考にさせていただきました。
* [ファイルのアップロードUploading Files | Flask docs](https://msiz07-flask-docs-ja.readthedocs.io/ja/latest/patterns/fileuploads.html)
    * secure_filenameの利用方法を参考にしました
* [MIMEタイプ一覧](https://support.mngsv.jp/mnl/set/n04/list/)
    * midiのMIMEタイプを調べるために見ました。