1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

概要

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

image.png

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

方針

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

フォルダ構成

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

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

インストール

pip install flask

server.py

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側はファイルストリームを受け取れるように、「その4」から一部変更しています(後述)

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

MIDI2MusicXML.pyとXFMidiFile.pyの改修

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

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

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が1ずれている。ヤマハだけ?
    
    # 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が2文字の要素の処理。
    """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の第1引数をNone、第2引数にfilepathを渡すことで、ファイルストリームとして処理できる。XFMidiFileの継承元のmido.MidiFileがファイルストリームを受け取る機能をそのまま使っているだけ。

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

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にしています。

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

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、簡単でいいですね。

参考記事

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?