この記事は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のプログラムです。以下、その一部を紹介します。
音符コマンド
C
~B
の文字はそれぞれドからシの音を表します。+
をつけると半音上がり、-
をつけると半音下がります。うしろに音の長さを表す数字をつけることができます。四分音符なら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を作っています。
tuple
やopt
は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::Sample
はf32
であるかのように扱います。
定義を見るとわかるように、Source
はIterator<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でミニ言語を作りたい方、音を出すプログラムを書きたい方にはおすすめできます。