カテゴリー: Tech

GoでSMF(MIDI)ファイルを読み込んでみた

はじめに

私の最近の趣味はDTMで、時々曲を作っているのですが、単に曲を作るだけでなく、その打ち込みデータを他のこともにも活用できないかと考えました。そこで、DTMで作成したMIDIデータをGoで読み込み、演奏情報を読み込み、音符情報に変換する部分をコードで書いてみました。

MIDIとは?

MIDIとは、電子楽器の演奏データを、機器間で転送するための規格です。例えば、MIDIキーボードでの演奏(INPUT)に対応して、音源から音を鳴らす(OUTPUT)ために、INPUT側の機器からOUTPUT側の機器にリアルタイムにデータを転送するための規格です。(実際には、INPUTがシーケンサであったり、OUTPUTが楽器以外にも照明機器など、さまざまなシーンで使用されています))
このMIDIデータをファイルに記録し、後から再生したり、楽譜として表示したりするための規格の1つにSMF(Standard Midi File)があります。今回はこのSMFを読み込んで、音符の情報に変換したいと思います。

SMFファイルについて

SMFファイルは、ヘッダチャンクとトラックチャンクという2つのチャンクから構成されています。ヘッダチャンクにはSMFファイル全体に関する内容が、トラックチャンクには演奏情報が含まれています。
詳しくは、こちらのサイトに詳しく解説されているのですが、最低限紹介しておきたい内容をピックアップします。

Tickと分解能

SMFでは、音の長さの最小単位がTickとなります。Tickの実際の音価(音の長さ)は、分解能によって決まります。分解能は、MIDIのヘッダ部に含まれていて、四分音符が何Tickで表されるかを示す数値です。分解能が細かければ細かいほど、きめ細かい間隔で音符を並べることができます。
例えば、分解能が1だと、四分音符=1となるため、4分音符より短い音符を置くことができません。通常や32分音符や、16分3連符など、より細かい音符を使うと思うので、分解能は480となっているシーケンサが多いようです。
(四分音符あたりの分解能を指定する他に、タイムコードによって分解能を決めることもできますが、ここでの説明は省略します。)

トラックチャンク

トラックチャンクには、演奏データが含まれます。今回は、音符に変換するにあたって必要な点のみ紹介します。

フィールド名 意味
Delta 480 tick 480 tick 待つ
Event ノートオン | キー番号: 72 | ベロシティ: 127 キー番号72 (真ん中のド) をベロシティ127で鳴らす
Delta 480 tick 480 tick 待つ
Event ノートオフ | キー番号: 72 キー番号72 (真ん中のド) を止める
・・・

上記のように、DeltaとEventを繰り返しながら演奏データが格納されていきます。EventはMIDIイベントのことで、今回はノートオンとノートオフのみを扱います。ちなみに、音を止めるために、ノートオンでベロシティ0と記録される場合もあるため、その場合でも音が止まったものとみなす必要があります。

gomidiを使ってSMFファイルを読み込む

それでは、実際にSMFファイルを読み込みたいと思います。

gomidiの導入

MIDIやSMFを扱うことができるgoのパッケージはいくつかあるのですが、その中でも最近もメンテナンスされているパッケージがgomidiです。

Gitlab:
https://gitlab.com/gomidi/midi

Documentation:
https://pkg.go.dev/gitlab.com/gomidi/midi/v2

以下のコマンドでgomidiを追加します。

go get gitlab.com/gomidi/midi/v2

実装

まずはソースの全景です。
今回は、SMFファイルの音符データを扱いやすくするために、音を鳴らすタイミング、音の長さ、キー、ベロシティをまとめたNote structを生成していきます。

package main

import (
 "fmt"

 "gitlab.com/gomidi/midi/v2/smf"
)

type Note struct {
 AbsTicks uint64 // 音符の開始位置 (tick)
 Velocity uint8  // ベロシティ(強さ)
 Key      uint8  // 押したキー(鍵盤)
 Value    uint32 // 音の長さ (tick)
}

func main() {
 // 1. SMFファイルの読み込み
 f, err := smf.ReadFile("kaeru.midi")
 if err != nil {
  panic("error")
 }

 // 2. 分解能を取得する
 ticks, ok := f.TimeFormat.(smf.MetricTicks)
 if !ok {
  panic("error")
 }
 fmt.Println(ticks)

 // 3. トラック1を読み込む (トラック0はConductor Track)
 t := f.Tracks[1]

 var absTicks uint64 // 経過tick

 // Midiメッセージの値を受け取るための変数
 var channel uint8
 var velocity uint8
 var key uint8

 startedNotes := make([]Note, 0, len(t))
 notes := make([]Note, 0, len(t))

 // 4. イベントをforループで取得していく
 for _, ev := range t {
  // 5. 直前のNoteからの経過時間(tick)をインクリメントしておくことで、絶対時間を保持する
  absTicks += uint64(ev.Delta)

  // 6. メッセージによって分岐
  switch {
  // 音の鳴りはじめメッセージ
  case ev.Message.GetNoteStart(&channel, &key, &velocity):
   startedNotes = append(startedNotes, Note{
    AbsTicks: absTicks,
    Velocity: velocity,
    Key:      key,
   })
  // 音の鳴り終わりメッセージ
  case ev.Message.GetNoteEnd(&channel, &key):
   delIdx := -1
   for i, v := range startedNotes {
    // 同じキーの鳴り始めイベントを探す
    if v.Key == key {
     // 音の長さを代入
     v.Value = uint32(absTicks - v.AbsTicks)
     notes = append(notes, v)
     delIdx = i
    }
   }
   // 見つかった音の鳴り始めメッセージを、startedNotesから削除する
   if delIdx >= 0 {
    startedNotes = startedNotes[:delIdx+copy(startedNotes[delIdx:], startedNotes[delIdx+1:])]
   }
  }
 }

 // 7. notesを出力
 for _, v := range notes {
  fmt.Printf("%+v\n", v)
  //分解能によって、8分音符何個分か
  fmt.Println(ticks.In64ths(v.Value) / 8)
 }
}

このソースコードについて説明します。

  1. SMFファイルの読み込み
    SMFファイルを読み込むと、 SMF型のインスタンスが取得できます。この中には、先ほど説明したヘッダチャンクとトラックチャンクに該当するフィールドを持っています。
  2. 分解能の取得 (ヘッダチャンク)
    分解能は1で取得したSMF内のtimeFormatフィールドで取得できます。こちらも先ほど説明した通り、四分音符あたりの分解能か、タイムコードによる分解能のどちらかの指定が入るため、それぞれに対応したMetricTicks型かTimeCode型のいずれかが入ります。
    いずれの型もTimeForma型を実装しているのですが、今回使用したいメソッドはMetricTicks型にしか実装されていなかったため、キャストしています。
  3. トラック1の読み込み (トラックチャンク)
    演奏データを読み込みます。(今回はトラック1のみにデータが入っています。)
    トラック0には、テンポや拍子など、全トラックに影響するデータが入っていて、実際の演奏情報はトラック1以降に入っています。
  4. MIDIイベントを順番に取得
    3で取得したトラックをforループで回すことで、イベントを取得します。
  5. 絶対時間の記録
    SMFファイル内には絶対時間は存在せず、イベント間のDeltaTime(経過時間)でのみ時間経過が表されているため、DeltaTimeを加算していくことで、絶対時間を保持します。
  6. メッセージによる分岐
    音の鳴り始めと鳴り終わりを取得するため、メッセージにより分岐しています。
    • ev.Message.GetNoteStart(&channel, &key, &velocity)は、メッセージがベロシティ1以上のノートオンメッセージである場合はtrueを返し、そうでなければfalseを返します。引数に与えられたポインタにメッセージの値を代入します。
      ノートオンの場合、Noteを生成して、startedNotesに一旦appendしておきます。
    • ev.Message.GetNoteEnd(&channel, &key)は、メッセージがノートオフメッセージ、もしくはベロシティ0のノートオンメッセージである場合はtrueを返し、そうでなければfalseを返します。引数に与えられたポインタに、メッセージの値を代入します。
      このメッセージである場合は、startedNotesから同じキーのNoteを探し、見つかった場合はその音の鳴り終わりとして、音の長さを代入し、notes配列にappendします。
  7. notesの出力
    ここで、 ticks.In64ths(v.Value)によって、Valueが実際に64分音符が何個分であるか取得しています。このメソッドの中身は次のようになっています。
    // In64ths returns the deltaTicks in 64th notes.
    // To get 32ths, divide result by 2.
    // To get 16ths, divide result by 4.
    // To get 8ths, divide result by 8.
    // To get 4ths, divide result by 16.
    func (q MetricTicks) In64ths(deltaTicks uint32) uint32 {
     if q == 0 {
      q = defaultMetric
     }
     return (deltaTicks * 16) / uint32(q)
    }

    64分音符は、4分音符の1/16の長さです。
    仮に、分解能(q)が1だったとして計算してみると、16がreturnされます。

さいごに

gomidiを使うと、SMFファイルがパースされ、MIDIメッセージを取り出せることが分かりました。今のところメンテナンスもされているので、おすすめです!

おすすめ書籍

カイザー

シェア
執筆者:
カイザー

最近の投稿

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

2週間 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

4週間 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前

FSMを使った状態管理をGoで実装する

はじめに 一般的なアプリケーシ…

3か月 前