LoginSignup
13
1

RustでMMLを実装した

Last updated at Posted at 2023-12-11

この記事はLabBase テックカレンダー 2023の12日目です。

はじめに

Music Macro Language (MML) の方言を設計し、Rustで実装しました。

名称はLMML Music Macro Languageの頭文字を取ってLMMLです。正式名称の中に頭字語自身が含まれています。こういったものは再帰的頭字語と呼ばれており、オシャレであるとされています。

MMLとは MMLとは、音楽をテキストで表現するための言語です。BASICに音楽演奏のためのミニ言語として組み込まれるなどの使われ方が多いようです。

厳密な仕様はなく、実装によって細部に違いがあります。

パソコンで動作する実装もLMML以外にいくつかあります。有名なものとしてテキスト音楽「サクラ」などがあります。

紹介

演奏例です。なお動画部分は編集で後付けしたものです。

対応するコードは以下です。

; Bad Apple!! feat. nomico

t320 l4

@0 v15
<ab>cd e.r8ag e.r8<a.r8> edc<b>
<ab>cd e.r8dc <bab>c <bagb>
<ab>cd e.r8ag e.r8<a.r8> edc<b>
<ab>cd e.r8dc <b.r8>c.r8 d.r8e.r8

@1 v10
<ab>cd e.r8ag e.r8<a.r8> edc<b>
<ab>cd e.r8dc <bab>c <bagb>
<ab>cd e.r8ag e.r8<a.r8> edc<b>
<ab>cd e.r8dc <b.r8>c.r8 d.r8e.r8

@3 v25
gaed e.r8de gaed e.r8
dedc <bga.r8 gab>c de<a.r8>
egga ede.r8 dega ede.r8
dedc <bga.r8 gab>c de<a.r8>

@4 v30
egga ede.r8 dega ede.r8
dedc <bga.r8 gab>c de<a.r8>
egga ede.r8 dega ede.r8
ab>c<b age.r8 dedc <bga.r8>

@0 v15
egga ede.r8 dega ede.r8
dedc <bga.r8 gab>c de<a.r8>
egga ede.r8 dega ede.r8
dedc <bga.r8 gab>c de<a.r8>
egga ede.r8 dega ede.r8
dedc <bga.r8 gab>c de<a.r8>
egga ede.r8 dega ede.r8
ab>c<b age.r8 dedc <bga.r8

LMMLにはいくつかのコマンドが存在します。それらのコマンドを並べたものがLMMLのプログラムです。以下、その一部を紹介します。

音符コマンド

CBの文字はそれぞれドからシの音を表します。+をつけると半音上がり、-をつけると半音下がります。うしろに音の長さを表す数字をつけることができます。四分音符なら4、八分音符なら8のように指定します。さらに、.を付けると付点音符になります。

  • c4 - ドの四分音符
  • c+8 - ド#の八分音符
  • d4. - レの付点四分音符
  • d+4. - レ#の付点四分音符

和音

[]で音符を囲むことにより和音を表すことができます。音符はルート音を先頭に、低い順に書きます。

和音の例

  • [ceg] - C Maj
  • [ace] - Am
  • [ga+df] - Gm7
; カノン進行 C/G - G - Am/E - Em - F/C - C - F/C - G/D
@4t40 [gce][gbd][eac][egb][cfa][ceg][cfa][dgb]

↓演奏結果

:コマンド

LMMLには0~15の16個のチャンネルがあり、これらを同時に演奏することができます。

:0のように書くと、それより後のコマンドはチャンネル0に対して作用します。

チャンネルの使用例

; 少女さとり ~ 3rd eye (東方地霊殿より)
; (最初の2小節のみ)

:0 @3 v20 t60 o4
:1 @3 v20 t60 o3
:2 @3 v30 t60 o2
:3 @4 v50 t60 o2
:4 @4 v20 t60 o4
:5 @4 v40 t60 o1

:0 l8
<b4.>c+16d16f+ <b>df+g<b>c+gf+2

:1 l2
f+1 gf+

:2 l2
f+1 gf+

:3 l32
crrrrrrr rrrrrrrr [d+g]rrrrr[d+g]r rrrr[d+g]rrr
<<[bg+f]rrrrrrr rrrr[bg+f]rrr >[g+f]<rrrrrrr rrrrrrrr>>

:4 l2
[bf+]1[bg][bf+]

:5 l2
[bf+]1[bg][bf+]

REPL

他のMML実装には無い(多分)特徴として、REPLを備えています。コードの一部を入力し、その場で音程やリズムを確かめることができます。耳コピ・作曲などに便利だと思います。

より詳細かつ厳密な文法や、自分のPCで動かす方法はリポジトリのREADMEに書いてあります。ぜひ遊んでみてください。

技術解説

使用した主なクレート

構文解析

構文解析にはnomを使用しました。nomはパーサコンビネーターであり、小さなパーサーを組み合わせて言語全体を解析できるパーサーを組み立てます。

例えば、LMMLの音符コマンドの文法(BNFによる定義)は以下の通りです。

<note-cmd> := <note-char> <modifier>? <number>? <dot>?

これをパースする関数は

/* 1 */ pub fn parse_note_command(input: &str) -> IResult<&str, LmmlCommand> {
/* 2 */     map(
/* 3 */         tuple((
/* 4 */             parse_note_char,
/* 5 */             opt(parse_modifier),
/* 6 */             opt(parse_number),
/* 7 */             parse_dot,
/* 8 */         )),
/* 9 */         |(note, modifier, length, is_dotted)| {
/*10 */             let modifier = modifier.unwrap_or(NoteModifier::Natural);
/*11 */             LmmlCommand::Note {
/*12 */                 note,
/*13 */                 modifier,
/*14 */                 length,
/*15 */                 is_dotted,
/*16 */             }
/*17 */         },
/*18 */     )(input)
/*19 */ }

です。3~8行目はBNFとほぼ一対一に対応します。9~16行目ではパース結果を受け取ってASTを作っています。

tupleoptはnomが用意している、パーサーを組み合わせるための関数です。parse_note_char等は自分で定義した関数です。

このような関数を非終端記号ごとに用意すれば、言語全体のパーサーが完成します。

音の再生

音の再生にはrodioというクレートを使用しました。

rodioにおいてもっとも重要なのはSourceトレイトです。Sourceトレイトを実装した型のインスタンスこそが音であり、再生したりミックスしたりフィルターをかけたりできます。

音をデジタルデータとして扱う場合、一定時間ごとに信号の振幅を記録します。したがって、音とは数値の羅列であると考えられます。ここでSourceの簡略化した定義を見てみましょう。

pub trait Source: Iterator
where 
    Self::Item: rodio::Sample
{
    fn current_frame_len(&self) -> Option<usize>;
    fn channels(&self) -> u16;
    fn sample_rate(&self) -> u32;
    fn total_duration(&self) -> Option<Duration>;
}

rodio::Sampleを実装する型としてf32があるので、以下ではrodio::Samplef32であるかのように扱います。

定義を見るとわかるように、SourceIterator<f32>にいくつかのメソッドを追加しただけのものです。したがって、基本的にはIterator<f32>を実装すればよいことになります。イテレータが返す値が、各サンプリングの値になります。

LMMLではノコギリ波、矩形波、パルス波、三角波、正弦波の5種類の波形を使用します。矩形波の簡略化した実装は以下のとおりです。

pub struct SawWave {
    frame: usize,
    frequency: f32,
}

impl Iterator for SawWave {
    type Item = f32;

    fn next(&mut self) -> Option<Self::Item> {
        self.frame += 1;
        if self.frame > (SAMPLE_RATE as f32 / self.frequency) as usize {
            self.frame = 0;
        }

        if self.frame < ((SAMPLE_RATE as f32 / self.frequency) * 0.5) as usize {
            Some(1.0)
        } else {
            Some(-1.0)
        }
    }
}


impl Source for SawWave {
    fn current_frame_len(&self) -> Option<usize> {
        None
    }

    fn channels(&self) -> u16 {
        1
    }

    fn sample_rate(&self) -> u32 {
        SAMPLE_RATE
    }

    fn total_duration(&self) -> Option<std::time::Duration> {
        None
    }
}

重要なのはnextメソッドです。frameの値を増やしながら1.0か-1.0を返しています(ただし適切なタイミングでframeを0に戻します)。このように、簡単な波形なら簡単に実装することができます。

終わりに

LMMLの開発において一番大変だったのは一貫性と納得感のある文法を考えることでした。言い換えれば、nomとrodioが非常に扱いやすく、実装上の負担にはなりませんでした。Rustでミニ言語を作りたい方、音を出すプログラムを書きたい方にはおすすめできます。

13
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
13
1