LoginSignup
27

More than 5 years have passed since last update.

RustでSMFパーサーを書く

Posted at

※この記事は文系的冗長性及び反知性的曖昧主義に支配されています1

現代楽譜学概論あるいは錆びついた頭にオイルを

Null安全2が一部界隈を賑わせた2016年11月。貴重な休日を湯水のように費やして不毛な作業に明け暮れる文系日曜プログラマーである私は、案の定、天啓を得ました。蘇る記憶。30億のデバイス。ヌルポ。ガッ。もういやだ、モダンなラングエッヂをライトしたい……!3

とはいえ、限られた時間で書く以上、目的をもって取り組みたいというもの。一通り思いを巡らせて浮かんだのが、C++を覚えるためにSMFパーサーを書いた経験。ビット演算からファイル入出力まで一通り必要となる上に、規模感が手頃で、新たな言語を覚えるのにそこそこ向いています。何よりちょうど、より高機能なものに書き直したいと思っていました。というわけで、C++の代替言語(と目される)RustでSMFパーサーライブラリghakufを書きました。この記事は、コードの解説と公開までの道のりを記すものです。

立てばPC、座ればDAW、歩く姿はSMF

ところでSMF(Standard MIDI File)をご存知ですか?MIDI4のファイルフォーマットの一つで、要するにデジタル音源に対応した音符と演奏記号の集合体(楽譜データ)です。「.mid」となっているファイルは十中八九これ。90年台後半に著作権を無視したSMFをやりとりする人が多発してしまったため、懐かしさを覚えるもといアンダーグラウンドな印象を受ける人がいるかもしれません。セスエムエフコワクナイ。ブンケイウソツカナイ。

SMFのファイル構造は以下のようなイメージ。バイナリデータ(≠プレーンテキスト)であることに注意。

SMF
├MThd          :4byte    ヘッダー
│├Length       :4byte    ヘッダーのサイズ。ヘッダーは形式が決まっているので必ず6(byte)。
│├SMF Type     :2byte    SMFのタイプ。普通は1。
│├Number Tracks:2byte    トラックの数
│└Time Division:2byte    時間単位。演奏タイミングの計算に使用。通常は正数で、4分音符あたりの分解能が入る。
├MTrk          :4byte    指揮者トラック(Conductor Track)
│├Length       :VLQ      トラックのサイズ
│├Meta Event              メタ情報
│├Meta Event              メタ情報
│├ ...
│└Meta Event   :3byte    トラックの終端を示すメタ情報(End of Track)
├MTrk          :4byte    トラック1
│├Length       :VLQ      トラックのサイズ
│├MIDI Event              演奏データ
│├MIDI Event              演奏データ
│├MIDI Event              演奏データ
│├MIDI Event              演奏データ
│├ ...
│└Meta Event   :3byte    トラックの終端を示すメタ情報(End of Track)
├MTrk          :4byte    トラック2
├ ...

基本的には、先頭にヘッダーがあって、その後にトラックと呼ばれるデータの塊が続きます。トラックには以下の3種類のデータのいずれかが格納されています。

イベント 概要 代表例
MIDIイベント いわゆる演奏情報。ここで発声スタート!とか、ここで演奏方法を変更!とか。 ノート・オン、ポリフォニックキープレッシャー、チャンネル・チェンジ
メタ・イベント 演奏情報以外のメタ情報。全体の演奏スピードや楽譜全体に適用される情報とか。 テンポ、著作権情報、トラック終端
システム・エクスクルーシブ・イベント 音源等に送信される特殊な情報。MIDIを通して機器間の通信を行う際に用いられる。 (SMFで使われているところを見たことない……)

このほか、可変長数値表現(VLQ)ランニングステータスが抑えておくべきポイントになりますが、以下のとおり良質な日本語の解説も多いため、詳細な仕様解説はそちらでご確認を。

ハッカーもすなるRustといふもの

流行りそうで流行らない少し流行っているイマドキ言語。そのコンセプトは、公式ガイドブックの頭書きに集約されています。

プログラミング言語Rust
Rustは安全性、速度、並行性の3つのゴールにフォーカスしたシステムプログラミング言語です。 ガーベジコレクタなしにこれらのゴールを実現していて、他の言語への埋め込み、要求された空間や時間内での動作、 デバイスドライバやオペレーティングシステムのような低レベルなコードなど他の言語が苦手とする多数のユースケースを得意とします。 全てのデータ競合を排除しつつも実行時オーバーヘッドのないコンパイル時の安全性検査を多数持ち、これらの領域をターゲットに置く既存の言語を改善します。 Rustは高級言語のような抽象化も含めた「ゼロコスト抽象化」も目標としています。 そうでありつつもなお低級言語のような精密な制御も許します。

この公式ガイドブックが非常に良くできていて、かつ最新の仕様がまとめられている5ので、これだけで初心者が十分に勉強できます。その他では、Rustの感触をつかむまで以下のサイトが大変役に立ちました。

ちなみに、半年ほどRustを触って最も得心が行ったのは以下のツイート。

Rustでプログラミングをするにあたって、エディターはAtomを採用しました。理由はとくにありませんが、私の開発環境がWindowsとMacなので、EmacsVimより使いやすいかなーというくらい6。参考までに、使用しているパッケージは以下のとおり。

パッケージ名 作者 概要
Atom Beautify Glavin001氏 コード整形を自動で行ってくれます。Rustにも対応。
Build Cargo (Atom公式) Rustのビルドシステム「Cargo」のコマンドをAtomに追加してくれます。「cargo doc --no-deps」も追加してほしい……。
Language Rust zargony氏 Rustのシンタックス・ハイライト(関数や変数といった意味に応じて色を変えるアレ)です。
Linter Rust (Atom公式) Rustの構文チェックをしてくれます。たまにうるさい。
Racer edubkendo氏 Rustのコード補完(コード入力中に、その続きの候補を出してくれるアレ)をしてくれます。事前にCargoからRacerをインストールしておく必要があります。

atom.png

マクガフィンたるSMF、ときどきRustのオノマトペ

前置きが長くなりましたが、SMFパーサーをRustのライブラリとして実装するまでの道のりは以下のとおり。完成形だけ知りたい方はレポジトリまたはドキュメントへどうぞ。

おたまじゃくしは電子音符の夢を見るか?

前節で簡単に紹介したとおり、SMFは基本的に各トラックに記述されたMIDIイベント等からなるため、これをRustで表現します。

pub enum Message {
    MetaEvent { delta_time: u32, event: MetaEvent, data: Vec<u8> },
    MidiEvent { delta_time: u32, event: MidiEvent },
    SysExEvent { delta_time: u32, event: SysExEvent, data: Vec<u8> },
    TrackChange,
}
pub enum MetaEvent {
    SequenceNumber,
    TextEvent,
    // 中略
    Unknown { event_type: u8 },
}
pub enum MidiEvent {
    NoteOff { ch: u8, note: u8, velocity: u8 },
    NoteOn { ch: u8, note: u8, velocity: u8 },
    // 中略
    Unknown { ch: u8 },
}
pub enum SysExEvent {
    // 中略
}

各イベントにはデルタタイムと呼ばれる時間情報が記録されており、また、可変長のデータが含まれることがあるため、まずは上位の列挙型7Messageで各イベントを記述し、それぞれの詳細を下位の列挙型(MetaEventMIDIEventSysExEvent)で定義しています。パーサーがSMFでイベントを発見するたびにMessageを作成します。

ちなみにTrackChangeというイベントはSMFに存在しませんが、後述のとおりパーサーの設計をイベント駆動型にするために定義しています。

イベントよみに与ふる書

今回は作成するのはライブラリなので、イベント駆動型8を採用し、Observerパターンで実装しています。Rustにクラスプロトタイプは存在しないため、トレイトでオブザーバーが満たすべき境界を実装します。

pub trait Handler {
    fn header(&mut self, format: u16, track: u16, time_base: u16) {
        let _ = (format, track, time_base);
    }
    fn meta_event(&mut self, delta_time: u32, event: &MetaEvent, data: &Vec<u8>) {
        let _ = (delta_time, event, data);
    }
    fn midi_event(&mut self, delta_time: u32, event: &MidiEvent) {
        let _ = (delta_time, event);
    }
    fn sys_ex_event(&mut self, delta_time: u32, event: &SysExEvent, data: &Vec<u8>) {
        let _ = (delta_time, event, data);
    }
    fn track_change(&mut self) {}
    fn status(&mut self) -> HandlerStatus {
        HandlerStatus::Continue
    }
}
pub enum HandlerStatus {
    Continue,
    SkipTrack,
    SkipAll,
}

パーサーがMIDIイベントを見つけるとfn midi_event(...)で、メタ・イベントを見つけるとfn meta_event(...)で、事前に登録されたオブザーバーに通知します。イベントを通知するだけではトラックの変わり目を伝えられないため、fn track_change(...)も用意しています。

その他、これ以上のパースが不要になった際にファイルIOをスキップするため、fn status(...)を通して、オブザーバーが状況を列挙型HandlerStatusでパーサーに伝えられるようにしています。

観測者にラブソングを

それはいよいよパーサーの解説。

pub struct Reader {
    file: io::BufReader<fs::File>,
    handlers: Vec<Box<Handler>>,
    path: path::PathBuf,
}
impl Reader {
    pub fn new(handler: Box<Handler>, path: &str) -> Result<Reader, ReadError> {
        let mut handlers: Vec<Box<Handler>> = Vec::new();
        handlers.push(handler);
        Ok(Reader {
            file: io::BufReader::new(fs::OpenOptions::new().read(true).open(&path)?),
            path: path::PathBuf::from(path),
            handlers: handlers,
        })
    }
    pub fn read(&mut self) -> Result<(), ReadError> {
        // 中略:Handlerの状態チェック
        self.file.seek(io::SeekFrom::Start(0))?;
        self.check_tag(Tag::Header)?;
        self.read_header_block()?;
        while self.check_tag(Tag::Track)? {
            // 中略:Handlerの状態チェック
            self.read_track_block()?;
        }
        Ok(())
    }
    // 中略
}

Reader::new(...)で観測者やSMFのファイルパスを登録するところからスタートします。Readerのメンバーhandlersがベクターであることからわかるとおり、観測者は複数登録できます9。準備ができればfn read(...)でパース開始。ヘッダーを読み終えた後、MIDIイベント等を読むためのメインループに入ります。

ちなみに、Rustでファイル入出力を行う際はEOFチェックとバッファリングの扱いが直感(C言語)と異なるため、注意する必要があります。

MIDIイベント等を読むコードは以下のとおり。トラック冒頭に記述されたデータサイズがつきるまで、発見したイベントを観測者に知らせ続けます。

fn read_track_block(&mut self) -> Result<&mut Reader, ReadError> {
    let mut data_size = self.file.read_u32::<BigEndian>()?;
    while data_size > 0 {
        // 中略:Handlerの状態チェック
        let delta_time = self.read_vlq()?;
        data_size -= delta_time.len() as u32;
        let mut status = self.file.read_u8()?;
        // 中略:ランニングステータスの処理
        data_size -= size_of::<u8>() as u32;
        match status {
            0xff => {
                // meta eventの処理
                let meta_event = MetaEvent::new(self.file.read_u8()?);
                data_size -= size_of::<u8>() as u32;
                let len = self.read_vlq()?;
                let data = self.read_data(&len)?;
                data_size -= len.len() as u32 + len.val();
                for handler in &mut self.handlers {
                    if handler.status() == HandlerStatus::Continue {
                        handler.meta_event(delta_time.val(), &meta_event, &data);
                    }
                }
            }
            0x80...0xef => {
                // 中略:midi eventの処理
            }
            0xf0 | 0xf7 => {
                // 中略:system exclusive eventの処理
            }
            _ => {
                // 中略:想定外のイベントが発見された際のエラー処理
            }
        };
    }
    Ok(self)
}

SMFの各イベントは先頭のステータスバイトでその種類を判別するため、当該バイトをmatchで振り分けて処理しています。ところで、SMFはビッグエンディアンで値が格納されているため、byteorderというクレートを使用しています。便利ですね。ちなみに、MetaEvent::new(...)なんてしれっと使っていますが、これは、Rustでは列挙体にメソッドを実装できるためです。その他、バイナリを作成するメソッドfn binary(...)等を独自に実装しています。

なお、今回はライブラリを作成しているため、エラー処理10を以下のとおり少し丁寧に書いています。

pub enum ReadError {
    InvalidHeaderTag { tag: [u8; 4], path: path::PathBuf },
    InvalidIdentifyCode { code: u32, path: path::PathBuf },
    InvalidTrackTag { tag: [u8; 4], path: path::PathBuf },
    Io(io::Error),
    NoValidHandler,
    UnknownMessageStatus { status: u8, path: path::PathBuf },
}
impl fmt::Display for ReadError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        use reader::ReadError::*;
        match *self {
            InvalidHeaderTag { tag, ref path } => {
                write!(
                    f,
                    "Invalid header tag '{:?}' has found: {}",
                    tag,
                    fs::canonicalize(&path).unwrap().display()
                )
            }
            // 中略
        }
    }
}
impl error::Error for ReadError {
    fn description(&self) -> &str {
        use reader::ReadError::*;
        match *self {
            InvalidHeaderTag { .. } => "(エラー文)",
            // 中略
        }
    }
}
impl From<io::Error> for ReadError {
    fn from(err: io::Error) -> ReadError {
        ReadError::Io(err)
    }
}

パース時に生じうる各種エラーを列挙体ReadErrorに掲げ、エラーを表現するerror::Errorトレイト及びデバッグ時の表示を定めるstd::fmt::Displayトレイトを実装しています。また、ReadErrorのうちstd::io::Errorstdモジュールに定義されるエラーですので、ReadErrorに変換するFromトレイトも実装しています。

SMFにかける橋

ところで、せっかくパーサーを作ったのだから、書き出しも揃えたくなりませんか?というわけでその完成形がこちら。

pub struct Writer {
    messages: Vec<Message>,
    // 中略
}
impl Writer {
    pub fn new() -> Writer {
        Writer {
            messages: Vec::new(),
            // 中略
        }
    }
    pub fn push(&mut self, message: Message) {
        self.messages.push(message);
    }
    // 中略
    pub fn write(&self, path: &str) -> Result<(), io::Error> {
        let path = path::Path::new(path);
        let mut file = io::BufWriter::new(fs::OpenOptions::new()
            .write(true)
            .truncate(true)
            .create(true)
            .open(path)?);
        // 中略:ヘッダー書き込み
        for message in self.messages.iter() {
            match *message {
                Message::TrackChange => {
                    file.write(&Message::TrackChange.binary())?;
                    // 中略:トラックサイズの書き込み
                }
                _ => {
                    // ランニングステータスに関する処理は省略しています
                    file.write(&message.binary())?;
                }
            }
        }
        Ok(file.flush()?)
    }
}

Messageを事前に配列の形で登録しておき、順に書き出しています。Messageをバイナリ化する面倒な処理は全てMessageに実装したfn binary(...)に分離することで見通しを良くしています。なお、std::io::BufWriterでファイルへの書き出しを行う場合、ライフタイムの終わりでエラーが出ても無視されてしまうため、最後にfn flush(...)を呼ぶのがお作法となっています。

貨物検査とハードボイルド・ワンダーランド

時代は便利になったもので、RustではCargoにより3種類のテスト11を書けます。例えば以下はtestsモジュールに記述したテスト。構造体VLQ及びVLQBuilderを記述しているファイルsrc/formats.rsに一緒に記述しています。

#[cfg(test)]
mod vlq_tests {
    use formats::*;
    #[test]
    fn binary_98327() {
        let tester = VLQBuilder::new().push(134).push(0b10000000).push(23).build();
        assert_eq!(tester.val(), 98327);
        assert_eq!(tester.len(), 3);
        assert_eq!(tester.binary(), [134, 0b10000000, 23]);
    }
    // 中略:境界値テスト等
}

ビット演算を多用するVLQもこれで(そこそこ)安心。また、testsディレクトリを作成してその中に結合テストを書くこともできます。例えば、以下のテストをtests/test.rsに記述しています。

extern crate byteorder;
extern crate ghakuf;

use byteorder::{BigEndian, WriteBytesExt};
use ghakuf::messages::*;
use ghakuf::writer::*;
use std::fs::{OpenOptions, File};
use std::io::prelude::*;
use std::io::Read;

#[test]
fn build_integration_testing() {
    let mut writer = Writer::new();
    let test_messages = test_messages();
    for message in test_messages {
        writer.push(message);
    }
    assert!(writer.write("tests/test_build.mid").is_ok());
    let mut data_write = Vec::new();
    let mut f = File::open("tests/test_build.mid").unwrap();
    f.read_to_end(&mut data_write).unwrap();
    let mut data_read = Vec::new();
    let mut f = File::open("tests/test.mid").unwrap();
    f.read_to_end(&mut data_read).unwrap();
    if data_read.len() == 0 || data_write.len() == 0 {
        assert!(false);
    }
    assert_eq!(data_read, data_write);
}

fn test_messages() -> Vec<Message> {
    let mut test_messages: Vec<Message> = Vec::new();
    let tempo: u32 = 60 * 1000000 / 102; //bpm:102
    test_messages.push(Message::MetaEvent {
        delta_time: 0,
        event: MetaEvent::SetTempo,
        data: [(tempo >> 16) as u8, (tempo >> 8) as u8, tempo as u8].to_vec(),
    });
    test_messages.push(Message::MetaEvent {
        delta_time: 0,
        event: MetaEvent::EndOfTrack,
        data: Vec::new(),
    });
    test_messages.push(Message::TrackChange);
    // 中略
    test_messages
}

そして、メソッド等の先頭に記述するドキュメントにもテストを書くことができます。以下のドキュメント、テスト及びコードは、src/reader.rsに記述しています。

/// Parses SMF messages and fires(broadcasts) handlers.
///
/// # Examples
///
/// ```
/// use ghakuf::messages::*;
/// use ghakuf::reader::*;
///
/// let mut reader = Reader::new(
///     Box::new(HogeHandler {}),
///     "tests/test.mid",
/// ).unwrap();
/// let _ = reader.read();
///
/// struct HogeHandler {}
/// impl Handler for HogeHandler {
///     // 中略
/// }
/// ```
pub fn read(&mut self) -> Result<(), ReadError> {
    // 中略
}

今回記述したテストはコードカバレッジを考慮していませんが、重要な部分の動作確認はなんとなく大体それなりに行っています。

「?」「!」

それでは、ライブラリが完成したので全世界に生き恥をさらしましょう。今回は、バージョン管理をGitで行った上で、コードをGitHubで公開しています。Git・GitHubの概要や使い方は、以下のサイトがわかりやすくてオススメです。

GitHubでの公開時は、レポジトリにREADME.mdを用意して使用例等を書いておくと、トップに表示されて格好良いかもしれません。ライセンスを記述したファイルも忘れずにご用意を。

github.png

また、CargoにはRustのコメントからドキュメントを作成する、Javadoc類似の機能が備わっています。複雑なルールがあるわけではないため、以下のサイトを参考に、簡単に作成してみました。

このドキュメントは、後述するcrates.ioへの登録時にdocs.rsで公開することができます。

doc_rs.png

作成したライブラリは、crates.ioに登録すれば、他の人が簡単に使えるようになります。登録方法は以下を参照。

cargo.png

Eppur ci muove!

ところで、GitHubやcrate.ioで「build passing」というバッヂを見かけたことはありませんか?あれは、継続的インテグレーションのテスト結果を表示するものです。今回は、GitHubと連携して、Linux環境とMac環境でのテストを行えるTravis CIと、Window環境でのテストを行えるAppVeyorを試しました。

Travis CIは、当該サイト上でGitHubと連携させた上で、レポジトリに.travis.yamlを配置することでオンラインテストを行えます。.travis.yamlは以下のサイトを参考に設定しました。

travis_ci.png

AppVeyorも、当該サイト上でGitHubと連携させた上で、レポジトリにappveyor.ymlを作成することでオンラインテストを行えます。appveyor.yamlは以下のサイトを参考に設定しました。

appveyor.png

設定ファイルは、上述のサイトのほか、crates.ioで上位に表示されているライブラリのレポジトリに登録されているものも参考にしています。なお。AppVeyorは、セキュリティの設定が変わったのか、VM上でRustコンパイラをダウンロードしようとすると2017年7月下旬からエラーが出るようになりましたが、以下のQ&Aを参考に設定を追加することで回避できました。

または私は如何にして心配するのを止めてRustを愛するようになったか

年年歳歳花相似、歳歳年年IT不同。プログラミング言語は百代の過客にして、行かふ技術も又旅人也。電子情報工学の進歩には目覚ましいものがありますが、当面はRustとお付き合いしていきたいと思います。最終的には、VSTホストやら歌声合成ライブラリやらを組みたいのですが、何年後になることやら12。ただ、この脳内ブームはしばらく続きそうです。

最後に、短いですが謝辞を。初心者の私が(拙いとはいえ)ライブラリを組めたのは、一重にSMF・Rustの解説記事を公開してくださっている皆さまのおかげです。特にわいやぎ氏、κeen氏におかれましては、足を向けて寝られません。願わくば、さらにこの記事がSMF界、Rust界の教師・反面教師として誰かの糧にならんことを。


  1. 良い記事を書くためのガイドライン?知らんなぁ……。 

  2. koher氏「null安全でない言語は、もはやレガシー言語だ」(Qiita記事、2016年11月)を参照。 

  3. あと5000兆円ほしい 

  4. Musical Instrument Digital Interface。電子楽器や電子音源の演奏情報。2017年6月にIECの規格※になったそうですが、さすがに読む気が……。※IEC 63035:2017 

  5. Rustは安定版のリリース(2015年5月)から日が浅く、仕様の破壊と再構築を繰り返していた頃の情報がネット上に散見されるため、公式ガイドブックが最も頼りになります。 

  6. やめて!石を投げないで! 

  7. Rustの列挙型(enum)は様々な型のうち一つを選択するもので、他言語ではタグ付き共用体とも呼ばれているようです。 

  8. XMLパーサーでいうところのSAXDOMではないしドムでもない。 

  9. 特に意味はない 

  10. Rust公式ガイドブックにエラーハンドリングが詳細に記述されています。必見。 

  11. Rust公式ガイドブックにテストが詳細に記述されています。必見。 

  12. Rust公式ガイドブックには特に記述されていません。再見。 

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
27