見出し画像

[第3章]iOS版GarageBandでメモった曲を、StudioOneで仕上げる(開発編)

こんばんは、最近「グラノーラ」と「Dear-Natura(ディアナチュラ)」にハマってるMaruCoo(まる)です。

前回の記事で、「iOS版GarageBandで打ち込んだ曲をMacBookのLogicProに取り込んでMIDIファイル書き出しを行うと、ノートの長さがおかしくなっちゃう」事象の原因調査を行ったので、今回は対策を検討したいと思います。

原因は「iOS版GarageBandでの打ち込み方法」っぽい

まずは、前回の振り返りです。

iPadのGarageBandで楽曲を作る際、「鍵盤でざっくりリアルタイム入力し、ピアノロールでクオンタイズかけたりノートを追加したりする」のですが、どうやら、「鍵盤での入力」と「ピアノロールでの入力」の違いによって、MIDIイベントの生成のされ方が異なり、結果、LogicProでの「MIDIファイル書き出し」でおかしなファイルが生成されてしまうようです。

実際に、iOS版GarageBandの

①鍵盤のみで入力
②ピアノロールのみで入力

の2パターンで簡単な楽曲を作成し、LogicProで書き出したMIDIファイルをバイナリレベルで比較してみたところ、①はOK、②はNGという結果が出ました。

シンセサイザーやMIDIキーボードなどのMIDI機器は、鍵盤を押した時に、音を鳴らすために「ノートオン」というMIDIイベントを出力します。鍵盤を離すと、音を止めるための「ノートオフ」というMIDIイベントを出力するのですが、MIDI規格的には下記の3パターンのいずれも音を止めることが出来るようです。

(a)ノートオン→ノートオフで音を止める。
(b)ノートオン→ベロシティ0のノートオンで音を止める。
(c)ノートオン→同じ音程のノートオンで次の音を鳴らし、前の音を止める。

このあたりの挙動の違いが根本原因の可能性大です。

オンスクリーンキーボードとピアノロールでMIDIデータを打ち込んでみた。

まず最初は、iOS版GarageBandのオンスクリーンキーボードを弾いて入力した曲です。これがオンスクリーンキーボードです。

画像1

前半はドレミファソラシドの単純な音階(四分音符)、中盤は3和音(二分音符)、後半はアルペジオ(八分音符)です。

画像2

お次は、リージョンを作成し、ピアノロールで1音ずつ入力していきます。

画像3

はい、できました。先ほどと同じく、前半はドレミファソラシドの単純な音階(四分音符)、中盤は3和音(二分音符)、後半はアルペジオ(八分音符)です。

画像4

もちろん、再生してみると、どちらも同じように聞こえます。

以下が、それぞれのGarageBandプロジェクトをLogicProで開いて書き出したMIDIファイルを、バイナリエディタで覗いてみたところです。

画像5

左側が「オンスクリーンキーボードで入力したもの。「ノートオン」の後に「ノートオフ」が出力されています。

一方、右側が「ピアノロール」で入力したもので、こちらは、「ノートオン」の後に「ノートオフ」が出力されていません。ノートオンイベントのデルタタイム(0x8360)が設定されているので、MIDIフォーマット的には正しいですね。

この違いが、MIDIファイル書き出し結果に影響しているんじゃないかと思います。

PythonでMIDIファイルを解析してみた

どうにも解決策が見つからないので、

正しいMIDIファイルに書き換えるツールを開発したらどうだろう?

と思い立ち、とりあえず、最近勉強し始めたPythonを使ってMIDIファイルを解析してみました。

★注意★ソースコードは動作確認用に作成したもので、テストはほぼ実施していません。参考・流用する場合は十分テストを実施したうえで、自己責任でご利用くださいね。

まずは、試しに、MIDIファイルを読み込んで、別のファイルにコピーするプログラムを書いてみました。ファイルオープンして1バイトずつ読み込んで新しいファイルに書き込むだけなので、MIDIファイルじゃなくてもいいんですけどね。

#!/usr/bin/env python
#-*-coding: utf-8 -*-
import os
# ファイル名設定
INPUT_FILE = 'SMF_test_GB_Step.mid'
OUTPUT_FILE = 'conv.mid'
# ファイルのパスを取得し、相対パスを取得
InputFilePath = os.path.join(os.path.dirname(__file__), INPUT_FILE)
OutputFilePath = os.path.join(os.path.dirname(__file__), OUTPUT_FILE)
# print (InputFilePath)
# MIDIファイルをバイナリ、Readで開く
fr = open(InputFilePath, 'rb')
# 変換後のMIDIファイルをバイナリ、Writeで開く
fw = open(OutputFilePath, 'wb')
# 1バイトずつ取得して、書き込み
while True:
 data = fr.read(1)
 # ファイルの最後か?
 if len(data) == 0:
   break
 # ファイルに書き込み
 fw.write(data)
# ファイルクローズ
fr.close()
fw.close()

まず、MIDIファイルの構造を調べます。ざっくり言うと、

ヘッダチャンク×1つ
トラックチャンク×トラック数分

という構成になってます。

MIDIファイルのヘッダチャンクを解析してみる。

まず、ヘッダチャンクの情報を取得してみます。こんな関数を作ってみました。先頭12byteを読み込んでるだけです。

def GetMIDIFormatNo(fr):
   fr.seek(0)
   if CheckMIDIFormat(fr) == True:
       HeadLength = fr.read(4)
       HeadFormat = fr.read(2)
       HeadTrackNum = fr.read(2)
       HeadTimeBase = fr.read(2)
       print(HeadLength)
       print(HeadFormat)
       print(HeadTrackNum)
       print(HeadTimeBase)
       return True
   else:
       print('NG2')
       return False

↓こんな感じの結果が表示されます。16進数なので何のこっちゃですね。。。

b'\x00\x00\x00\x06'
b'\x00\x01'
b'\x00\x02'
b'\x01\xE0'

ヘッダチャンク識別子 'MThd'(4byte)[\x4D\x54\x68\x64]

画面表示はしていませんが、ヘッダチャンクを示す文字列が「MThd」の4文字です。ファイルの先頭にこの文字列があれば、MIDIファイルだという事が分かります。

ヘッダチャンクのデータ長(4byte)[\x00\x00\x00\x06]

最初は、ヘッダチャンクのデータ長「6byte」を表していて、「この後、6byte分のデータがありますよ」という事を教えてくれています。このように、MIDIファイルは、「最初にデータ長があって、その後ろに、データ長分のデータが続く」という形で構成されている部分がよく出てきます。

MIDIフォーマット(2byte)[\x00\x01]

次はMIDIファイルのフォーマットです。フォーマットには「0」「1」「2」の3種類がありますが、このファイルは「フォーマット1」です。ちなみに、「2」はほとんど使われていないようです。

トラック数(2byte)[\x00\x02]

次はトラック数。「フォーマット1」の場合、1リージョンだけMIDIファイル書き出しした場合は「1」になりますが、楽曲全体をMIDIファイル書き出しすると、2以上の値が入ってきます。ちなみに、「フォーマット0」の場合は、トラック数は「1」固定です。

タイムベース(2byte)[\x01\xE0]

最後はタイムベース。「0x01E0」は10進数にすると「480」です。分解能とも呼びますが「四分音符をどれだけ細かく分割しているか?」の値です。この値が大きい場合はより細かく音の長さやタイミングをずらす事ができる、、、ということになります。LogicProは960、一般的なDAWは480、StudioOneは100のようです。

では、実際に書いたプログラムの一部をご紹介。

ヘッダチャンクの情報を格納するクラスを定義します。

# ヘッダチャンク
class HeadChunks():
   def __init__(self,Head:bytes,Length:bytes,Format:bytes,TrackNum:bytes,TimeBase:bytes,Other:bytes):
       self.Head = Head
       self.Length = Length
       self.Format = Format
       self.TrackNum = TrackNum
       self.TimeBase = TimeBase
       self.Other = Other
   def printProperty(self):

そして、オープンしたファイルからヘッダチャンクを読み込むメソッドを定義します。

######
# MIDIファイルヘッダチャンク取得
# param ファイルオブジェクト
# return ヘッダチャンクオブジェクト
######
def GetMIDIFormatHeaderChunks(fr):
   fr.seek(0)
   Head = fr.read(4)
   Length = fr.read(4)
   # データ取得
   IntLength = int.from_bytes(Length,byteorder='big', signed=False)
   HeadData = fr.read(IntLength)
   Format = HeadData[0:2]
   TrackNum = HeadData[2:4]
   TimeBase = HeadData[4:6]
   # 残りのバイトを取得。
   Other = HeadData[:IntLength-6]
   # ヘッダチャンクオブジェクト生成
   hc2 = HeadChunks(Head,Length,Format,TrackNum,TimeBase,Other)
   return(hc2)

MIDIファイルのトラックチャンクを解析してみる。

次に、トラックチャンクの情報を取得してみます。

トラックチャンクは、一見シンプルで、識別子とデータ長とデータが並んでいます。ただし、データにはMIDIイベントやエクスクルーシブイベントが格納されているし、各MIDIイベントの長さ(デルタタイム)は可変長で格納されているし、、、なので、解析は結構難解です。トラックチャンクを読み込んで、一部分を表示してみました。

b'MTrk'
b'\x00\x00\x01\xbe'
b'\x00\xff \x01\x00\x00\xff\x03\x0bGrand Piano\x00\xff\x04\x06Inst 3\x00\x90<d\x83`\x90<\x00\x00>d\x83`\x90>\x00\x00@d\x83`\x90@\x00\x00Ad\x83`\x90A\x00\x00Cd\x83`\x90C\x00\x00Ed\x83`\x90E\x00\x00Gd\x83`\~~~~~

トラックチャンク識別子 'MTrk'(4byte)[\x4D\x54\x72\x6B]

トラックチャンクを示す文字列が「MTrk」の4文字です。各トラックの先頭にこの文字列があり、トラックの開始を示しています。

トラックチャンクのデータ長(4byte)[\x00\x00\x01\xBE]

トラックチャンクのデータ長です。\x01\xBEは10進数に直すと「446」なので、「この後、446byte分のデータがありますよ」という事を教えてくれています。

データ(可変長)[\x00\xFF\x01\x00~~~~~]

446byteの中に、「識別子+データ長(固定長)+データ」「識別子+データ長(可変長)+データ」などのような形で、ノートや制御用のMIDIイベントが格納されています。詳細はを書きだすとなが~くなるので、興味のある方は参考にさせて頂いた下記サイトへGo!(汗)


ヘッダチャンクと同様に書いたプログラムが下記ですが、、、実は、MIDIイベントやシステム・エクスクルーシブ・メッセージイベント、メタイベントを処理するためのメソッドをいくつか実装しているので・・・・、ガッツリ割愛します・・・。

# トラックチャンク
class TrackChunks():
   Notes = []

   def __init__(self,Head:bytes,Length:bytes,Data:bytes):
       self.Head = Head
       self.Length = Length
       self.Data = Data

   def AppendNotes(self,Head:bytes,Note:bytes,Velocity:bytes,DeltaTime:bytes):
       Note = Notes(Head)
       self.Note.AppendNotes(Note,Velocity,DeltaTime)

   def printProperty(self):
       print(hc.Head)
       print(hc.Data)

# ノート
class Notes():
   chords = []

   def __init__(self,Head:bytes):
       self.Head = Head

   def AppendNotes(self,Note:bytes,Velocity:bytes,DeltaTime:bytes):
       chord = Chord(Note,Velocity,DeltaTime)
       self.chords.append(chord)

# コード
class Chord():
   def __init__(self,Note:bytes,Velocity:bytes,DeltaTime:bytes):
       self.Note = Note
       self.Velocity = Velocity
       self.DeltaTime = DeltaTime
######
# MIDIファイルトラックチャンク取得
# param ファイルオブジェクト
# return トラックチャンクオブジェクト
######
def GetMIDIFormatTrackChunks(fr):
   Head = fr.read(4)
   Length = fr.read(4)
   # データ取得
   IntLength = int.from_bytes(Length,byteorder='big', signed=False)
   Data = fr.read(IntLength)
   # トラックチャンクオブジェクト生成
   tc3 = TrackChunks(Head,Length,Data)
   return(tc3)

MIDIイベントに含まれるデルタタイムは可変長なので、別途メソッドを用意して処理しています。

######
# 可変長数値 取得処理
# param Data:バイト列(バイト数は問わず、通常4バイト)
# return tmpList:バイト列のリスト
######
def getValiableLengthValue(Data:bytes):
   # デルタタイム
   tmpList = []
   for intCount in range(len(Data)):
       # デルタタイム先頭バイト
       tmp = Data[intCount:intCount+1]
       intData = int.from_bytes(tmp,byteorder='big', signed=False)
       if intData < 128:
           # 最上位bitが"0"のため、デルタタイムは当バイトまで
           tmpList.append(tmp)
           break
       else:
           # 最上位bitが'1'のため、次の1Byteもデルタタイム
           tmpList.append(tmp)
   return tmpList

更に、メタイベントやシステム・エクスクルーシブ・メッセージイベントは、先頭の識別子によって構造が違うので、識別子を定義したうえで、パターン毎に処理を実装しています。

BYTE_SYSEX_F0 = b'\xF0' # システムエクスクルーシブ(0xF0)
BYTE_SYSEX_F7 = b'\xF7' # システムエクスクルーシブ(0xF7)

BYTE_META_EVENT = b'\xFF' # メタイベント
BYTE_META_SEQNO = b'\xFF\x00\x02' # シーケンス番号
BYTE_META_TEXT = b'\xFF\x01' # テキスト
BYTE_META_LICENSE = b'\xFF\x02' # 著作権表示
BYTE_META_TRACKNAME = b'\xFF\x03' # トラック番号
BYTE_META_INSTNAME = b'\xFF\x04' # 楽器名
BYTE_META_LYLIC = b'\xFF\x05' # 歌詞
BYTE_META_MARKER = b'\xFF\x06' # マーカー
BYTE_META_QUE = b'\xFF\x07' # キュー・ポイント

BYTE_META_CH = b'\xFF\x20' # MIDIチャンネルプリフィックス
BYTE_META_EOF = b'\xFF\x2F\x00' # エンド オブ ファイル
BYTE_META_TEMPO = b'\xFF\x51' # セット・テンポ
BYTE_META_SMPTE = b'\xFF\x54' # SMPTEオフセット
BYTE_META_BEAT = b'\xFF\x58' # 拍子
BYTE_META_KEY = b'\xFF\x59' # キー
BYTE_META_LOCAL = b'\xFF\x7F' # 固有メタイベント

処理はこんな感じ。

######
# エクスクルーシブデータ取得処理
# param tcData:バイト列(バイト数は問わず、通常4バイト)
#        Pointer:ポインタ
#        ByteMetaData:取得データ(戻り値用)
# return ByteMetaData:取得データ バイト列のリスト
######
def getByteMetaData(tcData:TrackChunks,Pointer:int,ByteMetaData:bytes):
   # ポインタ位置退避
   PointerStart = Pointer
   # メタイベント
   EventLength:int = len(ByteMetaData)
   Pointer += EventLength
   # データ長
   Length:bytes = tcData.Data[Pointer:Pointer+1]
   # print(Length)
   intLength:int = int.from_bytes(Length,byteorder='big', signed=False)
   EventLength += len(Length)
   Pointer += len(Length)
   # データ
   Data = tcData.Data[Pointer:Pointer+intLength]
   # print(Data)
   EventLength += len(Data)
   Pointer += len(Data)
   # 出力
   return tcData.Data[PointerStart:Pointer]

あれ?、よく考えたら、、、コレ、無理じゃね?

このプログラムを書いている最中に、ふと気がつきました。

MIDIイベントのノートオンの後ろにノートオフを追加してやればいいかぁ〜

と思っていましたけど、

ノートオンから何デルタタイム開けてノートオフを挿入すれば良いのか???
単純な曲なら何とかなるかもしれないけど、ギターのストロークやピアノの和音、ベースのゴーストノート、ドラムトラックなど、デルタタイムが機械的に算出できないんじゃね???

・・・と。

Pythonの勉強兼ねて始めたMIDIファイル解析ですが、先が見えなくなり、一気にテンションダウン。LogicProのトライアル期間も1ヶ月を切ってきたので、有識者のお力を拝借することにしました(汗)

次回(最終回)では、サポートコミュニティやSNS、サポートなどにお世話になりながら意外な結末にたどり着くまでを書いていきます。













記事を読んでいただき、ありがとうございます‼️ 少しでも皆様のお役に立つことが出来たら嬉しく思います☺️