2
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に変換 その1: 歌詞情報の取得

Last updated at Posted at 2022-11-23

概要

歌詞付きMIDI(XFフォーマット)をNEUTRINOに入力可能なMusicXMLに変換することを試みます。

シリーズ一覧は以下です。
歌詞付きMIDIをMusicXMLに変換 リンクまとめ

その1では、MIDIから歌詞情報を取得してみます。

結論だけ先に述べておくと、以下のコードで歌詞らしきものを抽出することができました。

import mido
from mido.midifiles.midifiles import *
from mido.midifiles.midifiles import _dbg
from mido.midifiles.meta import meta_charset, _charset

class XFMidiFile(mido.MidiFile):
    # override
    def _load(self, infile):
        if self.debug:
            infile = DebugFileWrapper(infile)

        with meta_charset(self.charset):
            if self.debug:
                _dbg('Header:')

            (self.type,
             num_tracks,
             self.ticks_per_beat) = read_file_header(infile)

            if self.debug:
                _dbg('-> type={}, tracks={}, ticks_per_beat={}'.format(
                    self.type, num_tracks, self.ticks_per_beat))
                _dbg()

            for i in range(num_tracks):
                if self.debug:
                    _dbg('Track {}:'.format(i))

                self.tracks.append(read_track(infile,
                                              debug=self.debug,
                                              clip=self.clip))
                # TODO: used to ignore EOFError. I hope things still work.
            
            # XFフォーマットに対応させるために以下を追記
            
            self.xfih = None # XFインフォーメーションヘッダの格納先
            self.xfkm = None # XFカラオケメッセージの格納先

            # midi trackの終了位置を記憶
            mtrk_end_index = infile.tell()
            # infileを最後まで読み込む
            rest_buf = infile.read()
            
            # XF Information Headerがあれば読み込む
            header = b'XFIH'
            if header in rest_buf:
                start_index = rest_buf.index(header)
                infile.seek(mtrk_end_index+start_index) # infileの位置を調整
                if self.debug:
                    _dbg('Track {}:'.format(header))

                self.xfih = self.read_xf_track(infile,
                                        debug=self.debug,
                                        clip=self.clip)
            # XF Karaoke Messageあれば読み込む
            header = b'XFKM'
            if header in rest_buf:
                start_index = rest_buf.index(header)
                infile.seek(mtrk_end_index+start_index) # infileの位置を調整
                if self.debug:
                    _dbg('Track {}:'.format(header))

                self.xfkm = self.read_xf_track(infile,
                                        debug=self.debug,
                                        clip=self.clip)
            
            # 念のため、infileのポジションをもとに戻す
            infile.seek(mtrk_end_index)
            # XFフォーマット対応のための追記ここまで

    # 新たに定義。read_trackを一部変えただけ
    @staticmethod
    def read_xf_track(infile, debug=False, clip=False):
        track = MidiTrack()

        name, size = read_chunk_header(infile)

        #if name != b'MTrk':
        if name not in (b'XFIH', b'XFKM'):# ヘッダーの条件を書き換え
            #raise IOError('no MTrk header at start of track')
            raise IOError('no XF header at start of track')# メッセージを変更

        if debug:
            _dbg('-> size={}'.format(size))
            _dbg()

        start = infile.tell()
        last_status = None

        while True:
            # End of track reached.
            if infile.tell() - start == size:
                break

            if debug:
                _dbg('Message:')

            delta = read_variable_int(infile)

            if debug:
                _dbg('-> delta={}'.format(delta))

            status_byte = read_byte(infile)

            if status_byte < 0x80:
                if last_status is None:
                    raise IOError('running status without last_status')
                peek_data = [status_byte]
                status_byte = last_status
            else:
                if status_byte != 0xff:
                    # Meta messages don't set running status.
                    last_status = status_byte
                peek_data = []

            if status_byte == 0xff:
                msg = read_meta_message(infile, delta)            

            elif status_byte in [0xf0, 0xf7]:
                # TODO: I'm not quite clear on the difference between
                # f0 and f7 events.
                msg = read_sysex(infile, delta, clip)
            else:
                msg = read_message(infile, status_byte, peek_data, delta, clip)

            track.append(msg)

            if debug:
                _dbg('-> {!r}'.format(msg))
                _dbg()

        return track

    @staticmethod
    def get_xflyricinfo(filepath):
        with open(filepath, "rb") as f:
            buf = f.read()
        
        # XF Karaoke Message チャンクの取得
        chunk_type_bytes = b'XFKM'
        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
_, _, _, lang = XFMidiFile.get_xflyricinfo("song/yorunikakeru/yorunikakeru.mid")
if lang == "JP":
    xfmidi = XFMidiFile("song/yorunikakeru/yorunikakeru.mid", charset="cp932")
else:
    xfmidi = XFMidiFile("song/yorunikakeru/yorunikakeru.mid")
print(xfmidi.xfkm)
MidiTrack([
  MetaMessage('cue_marker', text='$Lyrc:1:312:JP', time=0),
  MetaMessage('cue_marker', text='&s', time=5240),
  MetaMessage('lyrics', text='<', time=20),
  MetaMessage('lyrics', text='沈[し', time=20),
  MetaMessage('lyrics', text='ず]', time=240),
  MetaMessage('lyrics', text='む', time=240),
  MetaMessage('lyrics', text='よ', time=360),
  MetaMessage('lyrics', text='う', time=360),
  MetaMessage('lyrics', text='に', time=240),
  MetaMessage('lyrics', text='/', time=220),
  MetaMessage('lyrics', text='溶[と]', time=20),
  MetaMessage('lyrics', text='け', time=240),
  MetaMessage('lyrics', text='て', time=240),
  MetaMessage('lyrics', text='ゆ', time=240),
  MetaMessage('lyrics', text='く', time=240),
  MetaMessage('lyrics', text='よ', time=120),
  MetaMessage('lyrics', text='う', time=240),
  MetaMessage('lyrics', text='に', time=360),
  MetaMessage('lyrics', text='/', time=840),
  MetaMessage('lyrics', text='<', time=3460),
  MetaMessage('lyrics', text='二人[ふ', time=20),
  MetaMessage('lyrics', text='た', time=240),
  MetaMessage('lyrics', text='り]', time=240),
  MetaMessage('lyrics', text='だ', time=360),
...
  MetaMessage('lyrics', text='/', time=360),
  MetaMessage('cue_marker', text='&x', time=980),
  MetaMessage('lyrics', text='<エンディング', time=20),
  MetaMessage('end_of_track', time=0)])

背景

MusicXMLは楽譜情報を記述するフォーマットの一種で、NEUTRINOを始めとするいくつかの歌声合成ソフトウェアで歌唱音源を作成するときの入力とすることができます。ただし歌わせたい楽曲の歌詞付きMusicXMLが手軽に入手できるとは限りません。

MIDIは電子楽器の演奏情報を表現する規格の一つです。MIDIの中にはXFフォーマットという、カラオケ字幕表示に必要な情報を含んだフォーマットがあります。XFフォーマットのMIDIはヤマハミュージックデータショップなどで豊富な種類の楽曲について購入できます。

そこで、XFフォーマットのMIDIをMusicXMLに変換することができれば、MusicXMLが直接入手しにくい楽曲でも歌唱データを作りやすくなると期待できます。
今回はその取組の第一歩として、XFフォーマットのMIDIから歌詞情報を取得してみます。

関連取組

多くの先人が存在し、本取り組みのモチベーション・参考にさせていただいています。ありがとうございます。

歌詞付きMIDIファイル(XF仕様)をmusicxmlに変換

歌詞付きMIDIファイル(XF仕様)をmusicxmlに変換という、
XFフォーマットのMIDIをMusicXMLに変換するWebアプリが公開されています。
手元のデータ(後述のヤマハからDLした「夜に駆ける」)で試してみたところ、とても精度よくMusicXMLを作れていたのですが、形態素解析APIに起因するものか、ところどころ、音符と歌詞のずれがみられたため、限界を把握する意味でも自分で再実装してみることにしました。

kaibadash/midi2musicxmlmidi

MIDIと歌詞情報を入力として、MusicXMLを出力するアプリ実装(Kotlin)を公開してくださっています。

GitHub: kaibadash/midi2musicxml
ブログ:midiと歌詞からmusicxmlを生成してNEUTRINOに渡したい(1) | Pokosho!

READMEから想像するに、XFフォーマットでないMIDIと別途用意した歌詞情報を用いることを想定しているようです(違っていたらすみません)。手元のMacでトライしたところ、なぜか変換ボタンを押しても動かず、Kotlinに慣れていないため自力デバッグも難しかったので、自分の得意な言語(Python)で実装してみることにしました。

KLab カラオケアプリ

XFフォーマットのMIDIに基づいてカラオケアプリを実装した記録を公開してくださっています。

カラオケアプリを作ってみた話し | KLablog

最終出力はMusicXMLではありませんが、既成のMIDIパーサーを拡張してXFフォーマットに対応させる発想など、参考にさせていただきました。

実装(試行錯誤)

MIDIの入手

なんでもよかったのですが、ヤマハミュージックデータショップで以下を購入しました。

夜に駆ける YOASOBI

購入後、ちゃんとXFフォーマットのMIDIかどうか自信がなかったので、以下サイトでMusicXMLに変換してみました。結果、完璧ではないものの歌詞の情報を含んだMusicXMLが生成されました。

歌詞付きMIDIファイル(XF仕様)をmusicxmlに変換

素のMidoでXFフォーマットMIDIを読んでみる

Pythonでは、MidoというMIDIを読み書きするためのライブラリがあります。

GitHub: mido
Document: Mido - MIDI Objects for Python

MidoはXFフォーマットを想定していませんが、とりあえずそのまま読むとどうなるのか試してみます。

pip install mido
import mido
midi = mido.MidiFile("song/yorunikakeru/yorunikakeru.mid")
print(midi)

mido.MidiFileにMIDIのファイルパスを入力すると読み込んでくれます。パスは各自の環境にあったものに変えてください。

出力は以下のような感じです。中略しています。

MidiFile(type=0, ticks_per_beat=480, tracks=[
  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),
    Message('sysex', data=(67, 16, 76, 2, 1, 0, 1, 0), time=240),
    Message('sysex', data=(67, 16, 76, 2, 1, 2, 27), time=5),
    Message('sysex', data=(67, 16, 76, 2, 1, 4, 17), time=5),
    Message('sysex', data=(67, 16, 76, 2, 1, 32, 65, 0), time=5),
    Message('sysex', data=(67, 16, 76, 2, 1, 64, 75, 0), time=5),
    Message('sysex', data=(67, 16, 76, 2, 1, 66, 0, 10), time=5),
    Message('sysex', data=(67, 16, 76, 2, 1, 68, 0, 3), time=5),
    Message('sysex', data=(67, 16, 76, 2, 1, 70, 0, 52), time=5),
    Message('sysex', data=(67, 16, 76, 2, 1, 72, 0, 48), time=5),
    Message('sysex', data=(67, 16, 76, 2, 1, 88, 5), time=5),
    Message('sysex', data=(67, 16, 76, 2, 1, 89, 5), time=5),
    Message('sysex', data=(67, 16, 76, 2, 1, 90, 1), time=10),
    Message('control_change', channel=0, control=0, value=0, time=180),
...
    Message('note_off', channel=6, note=57, velocity=64, time=0),
    Message('control_change', channel=0, control=1, value=0, time=1841),
    MetaMessage('end_of_track', time=0)])
])

XFフォーマットを読み込ませてもエラーはでないようです。
ただし、出力結果に対して"lryic"のような文字列で検索をかけるとヒットがなく、歌詞情報は取れていないことがわかります。
ここからmidoを改造して、歌詞情報を取得することを目指します。

SMFフォーマットの勉強

XFフォーマットはSMFフォーマットという、いわゆる普通のMIDIの仕様を拡張したものなので、まずSMFフォーマットについて勉強します。以下のシリーズ記事が大変参考になりました。

JavaScriptでMIDIファイルを解析してみる 1

ざっくり以下のことを理解しました。

  • MIDIはバイナリファイル.バイト列で読み込んで解析する。
  • MIDIは「チャンク」と呼ばれる単位に分けられる。各チャンクは固有の文字列で開始される。
    • 1つ目のチャンクは「ヘッダーチャンク」。MIDIファイル全体の情報が格納される。
    • 2つ目以降のチャンクは「トラックチャンク」。音符の情報などが格納される。
  • トラックチャンクは「メッセージ」と呼ばれる単位に分けられる。「メッセージ」は(無印の)「メッセージ」と「メタメッセージ」の2種類がある。
  • SMFフォーマットでも歌詞は「メタメッセージ」に書ける。歌詞を格納するメタメッセージは16進数で「FF 05」から始まる。(ただしカラオケ字幕表示ができるほど厳密な情報が含まれている保証はない)

XFフォーマットの勉強

XFフォーマットの仕様書を読んで、内容をざっくりと理解します。

以下、本小節の画像は断りのない限り上記仕様書からの引用です。

データ構成

スクリーンショット 2022-11-23 21.07.47.png
(上図はp3より引用)

まず重要なこととして、XFフォーマットは上図のようにSMFフォーマットで書かれるチャンク(Header、Track)に、XF独自のチャンクを2つ(b'XFIH'で始まるXF Information Headerとb'XFKM'で始まるXF Karaoke Message Header)足したデータ構成となっています。

カラオケ情報はKaraoke Message Headerに書かれているので、ここをうまく解析できれば良いとわかります。

XF歌詞ヘッダー

仕様書p6ページに説明があります。続く歌詞イベント(メタメッセージ)がXFフォーマットに準拠していること、メロディーパート、文字コードなどの情報が格納されています。「FF 07 len text」というフォーマットになっており、今回のMIDIだと以下のようになっています。

\xff\x07\x0e$Lyrc:1:312:JP

末尾の「JP」は言語が日本語であることを意味します。またこのとき、文字コードはSHIFT-JIS(cp932)となります(p28)。文字コードは歌詞のバイト列をdecodeするために知っておくに必要があります。

カラオケメッセージ

具体的な歌詞の情報の記法についてです。
重要な点は以下です。

  • 歌詞を格納するためにSMFフォーマットの歌詞メタメッセージ(FF 05 len text)を利用している(p13)。したがってこれ自体はmidoで読めるはず。
  • 歌詞にはふりがなや制御文字が含まれることがある。制御文字の詳細はp14以降を参照。

MIDIを直接よむ

SMFフォーマット、XFフォーマットについて軽く理解したうえで、実際のデータを見てみます。

まず今回用意したMIDIがXF独自のチャンクを持っているか確認してみます。

# midiファイルのパス
PATH = "song/yorunikakeru/yorunikakeru.mid"
# バイナリとして読み込む
with open(PATH, "rb") as f:
  buf = f.read()
# XF Information Headerの存在確認
chunk_type_bytes = b'XFIH'
if chunk_type_bytes in buf:
  index = buf.index(chunk_type_bytes)
  print(buf[index:index+100]) # chunk_typeの位置以降100文字を表示

# XF Karaoke Message Headerの存在確認
chunk_type_bytes = b'XFKM'
if chunk_type_bytes in buf:
  index = buf.index(chunk_type_bytes)
  print(buf[index:index+100]) # chunk_typeの位置以降100文字を表示
b'XFIH\x00\x00\x00\x90\x00\xff\x016XFhd:2019/12/15:JP:J.Pop::23:f1:Ayase:Ayase::YOASOBI::\x00\xff\x01NXFln:JP:\x96\xe9\x82\xc9\x8b\xec\x82\xaf\x82\xe9(\x82\xe6\x82\xc9\x82\xa9\x82\xaf\x82\xe9)'
b'XFKM\x00\x00\x1a\xc9\x00\xff\x07\x0e$Lyrc:1:312:JP\xa8x\xff\x07\x02&s\x14\xff\x05\x01<\x14\xff\x05\x05\x92\xbe[\x82\xb5\x81p\xff\x05\x03\x82\xb8]\x81p\xff\x05\x02\x82\xde\x82h\xff\x05\x02\x82\xe6\x82h\xff\x05\x02\x82\xa4\x81p\xff\x05\x02\x82\xc9\x81\\\xff\x05\x01/\x14\xff\x05\x06\x97n[\x82\xc6]\x81'

確かに仕様書通りのバイト列で始まるチャンクがあるようです。
歌詞情報が格納されているのはb'XFKM'で始まるカラオケメッセージチャンクです。この中にあるXFインフォーメーションヘッダーを取り出してみます。「FF 07」を探して、データ長だけ切り出せばよいです。

# XF Karaoke Message チャンクの取得
chunk_type_bytes = b'XFKM'
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(":")
print(information_header)
print("id: ", id)
print("melody_channel: ", melody_channel)
print("offset:", offset)
print("language:", lang)
b'\xff\x07\x0e$Lyrc:1:312:JP'
id:  $Lyrc
melody_channel:  1
offset: 312
language: JP

取り出せました。
次に歌詞イベントを取り出してみます。「FF 05」で分割して、各要素の先頭からデータ長だけ切り出せばよいです。

# 歌詞メタイベントの取得
# information_headerの終点indexを取得。歌詞はそこより後ろに書かれているはずなので。
information_header_start_index = xfkm_bytes.index(b'\xff\x07')
information_header_data_length = xfkm_bytes[information_header_start_index+2] # FF 07 len textという構成
information_header_end_index = information_header_start_index+3+information_header_data_length

# 「FF 05」で分割。decode時の文字コードは横着して決め打ち
charset = "cp932"
cnt = information_header_end_index
while cnt < len(xfkm_bytes):
  subbytes = xfkm_bytes[cnt:]
  header = b'\xff\x05'
  if header in subbytes:
    start_index = subbytes.index(header)
    data_length = subbytes[start_index+2]
    end_index = start_index+3+data_length
    text = subbytes[start_index+3:end_index]
    print(cnt)
    print("bytes:", subbytes[start_index:end_index])
    print("lyric:", text.decode(charset) )
    print("")
    cnt = cnt+end_index
  else:
    break
26
bytes: b'\xff\x05\x01<'
lyric: <

38
bytes: b'\xff\x05\x05\x92\xbe[\x82\xb5'
lyric: 沈[し

47
bytes: b'\xff\x05\x03\x82\xb8]'
lyric: ず]

55
bytes: b'\xff\x05\x02\x82\xde'
lyric: む

62
bytes: b'\xff\x05\x02\x82\xe6'
lyric: よ

69
bytes: b'\xff\x05\x02\x82\xa4'
lyric: う

いい感じに歌詞情報が取り出せています。
最終的には、歌詞と同時に、歌詞に対応する音符(本質的にはタイムスタンプ)情報を取得できる必要があります。その情報の書き方はSMFフォーマットに準拠しているはずなので、midoの実装をうまく利用したいところです。

midoの読み込み処理を勉強

midoのソースコードを読んで処理の流れを掴みます。

  • mido/mido/midifiles/midifiles.pymido.MidiFileクラスが定義されている。
  • 同クラスの__init__を見ると、midiのファイルストリームを_loadメソッドに渡している
  • _loadメソッドを見ると、read_file_headerでトラック数(num_tracks)を取得し、トラック数だけread_trackを実行し、戻り値をself.tracksにappendしている
  • read_track関数も同じファイル(midifiles.py)で記述されている。中身を読むと、chunk_headerの名前(name)がb'MTrk'、つまり、SMFフォーマットのMIDIトラック以外のときはIOErrorを投げるようになっている。

という処理になっていました。_loadにおいて、トラック数分しかread_trackを実行しないため、SMFフォーマットのmiditrackだけがparseされ、後ろに書かれたXFフォーマット独自のチャンクは読み込まれていない、ということのようです。

そこで、infileにXFIHとXFKMが存在する場合には、追加で、read_trackを実行することで、XFフォーマットにも対応させられそうです。
ただしread_trackはMtrkチャンクしか読めないため、XFIH、XFKMも読めるように修正する必要があります。

mido.MidiFileを継承して機能追加

ソースコードを直接書き換えるのは怖いので、mido.MidiFileを継承したクラスをつくり、一部処理を上書きすることで対応します。ちなみにmidoのソースコードはMITライセンスです。

上書き対象となるメソッド(_load、read_track)で使われる関数をまとめてインポートします。面倒なので、アスタリスクを使っていますが、気になる方は必要なものに絞ってください。

import mido
from mido.midifiles.midifiles import *
from mido.midifiles.midifiles import _dbg
from mido.midifiles.meta import meta_charset, _charset

mido.MidiFileを継承したクラスを定義し、_loadメソッドをオーバーライドします。
元の処理に対して、「# XFフォーマットに対応させるために以下を追記」のコメント以降を追記します。

class XFMidiFile(mido.MidiFile):
  # override
    def _load(self, infile):
        
        if self.debug:
            infile = DebugFileWrapper(infile)

        with meta_charset(self.charset):
            if self.debug:
                _dbg('Header:')

            (self.type,
             num_tracks,
             self.ticks_per_beat) = read_file_header(infile)

            if self.debug:
                _dbg('-> type={}, tracks={}, ticks_per_beat={}'.format(
                    self.type, num_tracks, self.ticks_per_beat))
                _dbg()

            for i in range(num_tracks):
                if self.debug:
                    _dbg('Track {}:'.format(i))

                self.tracks.append(read_track(infile,
                                              debug=self.debug,
                                              clip=self.clip))
                # TODO: used to ignore EOFError. I hope things still work.
            
            # XFフォーマットに対応させるために以下を追記
            
            self.xfih = None # XFインフォーメーションヘッダの格納先
            self.xfkm = None # XFカラオケメッセージの格納先

            # midi trackの終了位置を記憶
            mtrk_end_index = infile.tell()
            # infileを最後まで読み込む
            rest_buf = infile.read()
            
            # XF Information Headerがあれば読み込む
            header = b'XFIH'
            if header in rest_buf:
                start_index = rest_buf.index(header)
                infile.seek(mtrk_end_index+start_index) # infileの位置を調整
                if self.debug:
                    _dbg('Track {}:'.format(header))

                self.xfih = self.read_xf_track(infile,
                                        debug=self.debug,
                                        clip=self.clip)
            # XF Karaoke Messageあれば読み込む
            header = b'XFKM'
            if header in rest_buf:
                start_index = rest_buf.index(header)
                infile.seek(mtrk_end_index+start_index) # infileの位置を調整
                if self.debug:
                    _dbg('Track {}:'.format(header))

                self.xfkm = self.read_xf_track(infile,
                                        debug=self.debug,
                                        clip=self.clip)
            
            # 念のため、infileのポジションをもとに戻す
            infile.seek(mtrk_end_index)
            # XFフォーマット対応のための追記ここまで

さらに上書きされた_loadメソッド内で使っているread_xf_trackもメソッドとして定義します。

class XFMidiFile(mido.MidiFile):
    # override
    def _load(self, infile):
        """"""

    # 新たに定義。read_trackを一部変えただけ
    @staticmethod
    def read_xf_track(infile, debug=False, clip=False):
        track = MidiTrack()

        name, size = read_chunk_header(infile)

        #if name != b'MTrk':
        if name not in (b'XFIH', b'XFKM'):# ヘッダーの条件を書き換え
            #raise IOError('no MTrk header at start of track')
            raise IOError('no XF header at start of track')# メッセージを変更

        if debug:
            _dbg('-> size={}'.format(size))
            _dbg()

        start = infile.tell()
        last_status = None

        while True:
            # End of track reached.
            if infile.tell() - start == size:
                break

            if debug:
                _dbg('Message:')

            delta = read_variable_int(infile)

            if debug:
                _dbg('-> delta={}'.format(delta))

            status_byte = read_byte(infile)

            if status_byte < 0x80:
                if last_status is None:
                    raise IOError('running status without last_status')
                peek_data = [status_byte]
                status_byte = last_status
            else:
                if status_byte != 0xff:
                    # Meta messages don't set running status.
                    last_status = status_byte
                peek_data = []

            if status_byte == 0xff:
                msg = read_meta_message(infile, delta)            

            elif status_byte in [0xf0, 0xf7]:
                # TODO: I'm not quite clear on the difference between
                # f0 and f7 events.
                msg = read_sysex(infile, delta, clip)
            else:
                msg = read_message(infile, status_byte, peek_data, delta, clip)

            track.append(msg)

            if debug:
                _dbg('-> {!r}'.format(msg))
                _dbg()

        return track

もとになっているのはread_track関数で、以下を書き換えただけです。ほかは書き換えていません。メインの読み込み処理をXFフォーマットの独自チャンクに対してそのまま流用できるか、少し不安ですが、XFカラオケメッセージでは歌詞はSMFフォーマットの歌詞イベントを利用して書かれているので、問題なく使えるとは思います。いったんはこのまま突き進んでみます。

        #if name != b'MTrk':
        if name not in (b'XFIH', b'XFKM'):# ヘッダーの条件を書き換え
            #raise IOError('no MTrk header at start of track')
            raise IOError('no XF header at start of track')# メッセージを変更

MIDIファイルを読み込んでみます。

xfmidi = XFMidiFile("song/yorunikakeru/yorunikakeru.mid")

エラーなく読み込めました。XFカラオケメッセージはself.xfkmに格納されています。
printしてみます。

print(xfmidi.xfkm)
Output exceeds the size limit. Open the full output data in a text editor
MidiTrack([
  MetaMessage('cue_marker', text='$Lyrc:1:312:JP', time=0),
  MetaMessage('cue_marker', text='&s', time=5240),
  MetaMessage('lyrics', text='<', time=20),
  MetaMessage('lyrics', text='\x92¾[\x82µ', time=20),
  MetaMessage('lyrics', text='\x82¸]', time=240),
  MetaMessage('lyrics', text='\x82Þ', time=240),
  MetaMessage('lyrics', text='\x82æ', time=360),
  MetaMessage('lyrics', text='\x82¤', time=360),
  MetaMessage('lyrics', text='\x82É', time=240),
  MetaMessage('lyrics', text='/', time=220),
  MetaMessage('lyrics', text='\x97n[\x82Æ]', time=20),
  MetaMessage('lyrics', text='\x82¯', time=240),
  MetaMessage('lyrics', text='\x82Ä', time=240),
  MetaMessage('lyrics', text='\x82ä', time=240),
  MetaMessage('lyrics', text='\x82\xad', time=240),
  MetaMessage('lyrics', text='\x82æ', time=120),
  MetaMessage('lyrics', text='\x82¤', time=240),
  MetaMessage('lyrics', text='\x82É', time=360),
  MetaMessage('lyrics', text='/', time=840),
  MetaMessage('lyrics', text='<', time=3460),
  MetaMessage('lyrics', text='\x93ñ\x90l[\x82Ó', time=20),
  MetaMessage('lyrics', text='\x82½', time=240),
  MetaMessage('lyrics', text='\x82è]', time=240),
  MetaMessage('lyrics', text='\x82¾', time=360),
...
  MetaMessage('lyrics', text='/', time=360),
  MetaMessage('cue_marker', text='&x', time=980),
  MetaMessage('lyrics', text='<\x83G\x83\x93\x83f\x83B\x83\x93\x83O', time=20),
  MetaMessage('end_of_track', time=0)])

ちゃんと歌詞らしきものが「lyric」というタイプのMetaMessageとして読めています。
またXFフォーマット仕様書で「FF 07」から始まっていた歌詞情報ヘッダーは「cue_marker」というタイプのMetaMessageに格納されているようです。

ただ歌詞については日本語になっていません。16進数のままっぽいかとおもいきや、変な文字も入っていてどうやら文字化けしているようです。

文字コードの修正

midoを継承したクラスによって歌詞らしきものを読み込むことができましたが、どうやら文字化けしているようですので、修正します。結論としては、コンストラクタに正しい文字コード(cp932)を渡せば修正できます。

まず文字化けの原因ですが、meta.pyで定義されているデフォルトの文字コードである"latin1"(_charsetという変数に格納)でdecodeされているためのようです。
もう少し丁寧には、MetaMessageの読み込み時に以下のような処理が行われています。

  • midifile.pyのread_track関数(_loadでつかっているやつ)において、MetaMessageを読み込む際にはbuild_meta_messageという関数が使われている。build_meta_messageはmeta.pyで定義されている。
  • build_meta_messageの中でMetaSpec_hoge(hogeはMetaMessageのタイプ)のクラスのインスタンスを作ってdecodeメソッドを呼んでいる。
  • decodeの処理はMetaSpecの種類ごとに微妙に異なるが、基本的には組み込み型bytearrayのdecodeを実行している。decode時の文字コードには_charsetが渡されている。

ということで、現状は、_charsetのデフォルト値(latin1)でdecodeされるのですが、XFフォーマットの日本語の場合の文字コードはSHIFT-JIS(cp932)なので、文字化けしているようです。

素のmido.MidiFileでは、文字コードをコンストラクタに渡すことで、任意の文字コードでdecodeできます。継承したXFMidiFileでも同様に、文字コードを渡すことで解決できます。

xfmidi = XFMidiFile("song/yorunikakeru/yorunikakeru.mid", charset="cp932")
print(xfmidi.xfkm)
MidiTrack([
  MetaMessage('cue_marker', text='$Lyrc:1:312:JP', time=0),
  MetaMessage('cue_marker', text='&s', time=5240),
  MetaMessage('lyrics', text='<', time=20),
  MetaMessage('lyrics', text='沈[し', time=20),
  MetaMessage('lyrics', text='ず]', time=240),
  MetaMessage('lyrics', text='む', time=240),
  MetaMessage('lyrics', text='よ', time=360),
  MetaMessage('lyrics', text='う', time=360),
  MetaMessage('lyrics', text='に', time=240),
  MetaMessage('lyrics', text='/', time=220),
  MetaMessage('lyrics', text='溶[と]', time=20),
  MetaMessage('lyrics', text='け', time=240),
  MetaMessage('lyrics', text='て', time=240),
  MetaMessage('lyrics', text='ゆ', time=240),
  MetaMessage('lyrics', text='く', time=240),
  MetaMessage('lyrics', text='よ', time=120),
  MetaMessage('lyrics', text='う', time=240),
  MetaMessage('lyrics', text='に', time=360),
  MetaMessage('lyrics', text='/', time=840),
  MetaMessage('lyrics', text='<', time=3460),
  MetaMessage('lyrics', text='二人[ふ', time=20),
  MetaMessage('lyrics', text='た', time=240),
  MetaMessage('lyrics', text='り]', time=240),
  MetaMessage('lyrics', text='だ', time=360),
...
  MetaMessage('lyrics', text='/', time=360),
  MetaMessage('cue_marker', text='&x', time=980),
  MetaMessage('lyrics', text='<エンディング', time=20),
  MetaMessage('end_of_track', time=0)])

歌詞情報の文字コード(言語)を事前に取得

XFMidiFileのコンストラクタに文字コードを渡すことで文字化けが解消されましたが、読み込むMIDIが常に日本語とは限らないため、丁寧に処理する場合は、MIDIの文字コード(言語)を別途確認する手段があったほうが良さそうです。そこで、XF歌詞ヘッダーから情報を抽出する関数を作ります。

XFMidiFileクラスにstaticmethodとして追加します。

def XFMidiFile(mido.MidiFile):
    def _load(self, infile):
        """"""

    @staticmethod
    def read_xf_track(infile, debug=False, clip=False):
        """"""

    # 追加
    @staticmethod
    def get_xflyricinfo(filepath):
        with open(filepath, "rb") as f:
            buf = f.read()
        
        # XF Karaoke Message チャンクの取得
        chunk_type_bytes = b'XFKM'
        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

試してみます。

id, melody_channel, offset, lang = XFMidiFile.get_xflyricinfo("song/yorunikakeru/yorunikakeru.mid")
print((id, melody_channel, offset, lang))
('$Lyrc', '1', '312', 'JP')

いい感じです。これで不安な場合は文字コードを事前に確認できるようになりました。

おわりに

かなり荒い実装ですが、midoを継承したクラスをXFフォーマットの読み取り荷対応させることができました。
ここからMusicXMLに変換するには少なくとも以下の処理が必要になりそうですので、次回以降でまたトライしたいと思います。

  • 歌詞と音符(タイミング)の対応付け
  • 制御文字の削除

参考

MusicXML 4.0
https://www.w3.org/2021/06/musicxml40/

MIDI 1.0
https://amei.or.jp/midistandardcommittee/MIDI1.0.pdf

ヤマハミュージックデータショップ
https://yamahamusicdata.jp/

歌詞付きMIDIファイル(XF仕様)をmusicxmlに変換
https://www.lemorin.jp/service/0l_midixf2musicxml.html

kaibadash/midi2musicxml
https://github.com/kaibadash/midi2musicxml

midiと歌詞からmusicxmlを生成してNEUTRINOに渡したい(1) | Pokosho!
https://pokosho.com/b/archives/3768

カラオケアプリを作ってみた話し | KLablog
https://www.klab.com/jp/blog/tech/2021/karaoke-202108.html

mido - GitHub
https://github.com/mido/mido

Mido - MIDI Objects for Python
https://mido.readthedocs.io/en/latest/

JavaScriptでMIDIファイルを解析してみる 1
https://qiita.com/PianoScoreJP/items/2f03ae61d91db0334d45

XFフォーマットの仕様書
https://jp.yamaha.com/files/download/other_assets/7/321757/xfspc.pdf

io --- ストリームを扱うコアツール
https://docs.python.org/ja/3/library/io.html

2
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
2
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?