以下の内容はhttps://bakkyalo.hatenablog.jp/entry/2024/06/01/043217より取得しました。


Tone.js で月光 3 楽章を演奏する

ブラウザで音を鳴らすことができる Tone.js を使って、ベートーヴェンの月光 3 楽章の冒頭 14 小節を演奏させてみたというだけの記事です。
とはいいつつ、記事の大半が Tone.js チュートリアルになっています。

【注意】当記事は asyncawaitPromise も何もかも分かってない引きこもりのダメニートによる戯言ですので、開発者の方はこんなところに来ていないで 公式の document をご参照ください。

完成品

最終的にこうなります。
youtu.be

Windows11 の XBox Game Bar でキャプチャしている関係上、実際よりも遅延が大きく見えますが、実際はそこまで酷くはないです。

また、読み込む楽譜を変えることで演奏する曲を変えることも可能です。

youtu.be


ブラウザ上での実演は
bakkyalo.github.io
↑ ここから確認できます。



今回の記事では、Tone.js の基本を「作りながら学ぶ」形式で紹介しつつ、最終的に上のような状態になるまでの道筋をすこし書いておこうかなと思います。割と好き勝手に書いているので長らくお付き合いお願いします。

bottom up 式ではなく、完成の状態から逆算したいという方も多いかと思われます。こちらに今回作成したものに関係するコードを示します。

種類 リンク
本体 (html) mypage/piano-88keys.html at master · bakkyalo/mypage · GitHub
鍵盤 (scss) mypage/css/piano-88keys.scss at master · bakkyalo/mypage · GitHub
譜面 (json) mypage/json at master · bakkyalo/mypage · GitHub


Tone.js の基本

tonejs.github.io

Web ブラウザ上で音を鳴らすための一般的なツールは Web Audio API と呼ばれるものですが、Tone.js はそれを直感的に触りやすくしてくれるツールになっています。

導入

Node.js 等に載っけることもできますが、GitHub Pages で簡単に遊べるように今回は CDN 経由で Tone.js を使うことにします。
HTML の head の中に以下を入れておけばそれで ok です。

<script src="https://unpkg.com/tone"></script>

【2025/03/31 追記】最近 unpkg が死んでるっぽいので別の CDN にするか、ローカルに .js (.min.js) を持ってくる方が良いかもしれません。


また、"Play" ボタンを押してから音が鳴るように、HTML の body に以下のようなものを作ることにします。

<input type="button" value="Play" id="playButton">


とりあえず鳴らしてみる - triggerAttackRelease()

以下を HTML の body の末尾に置いてください。*1

document.getElementById("playButton").addEventListener("click", () => {
    const synth = new Tone.Synth().toDestination();
    synth.triggerAttackRelease("C4", "8n");
});

これで "Play" ボタンを押すと「真ん中のド」(C4) が鳴るようになるはずです。

  • Synth() で音源を作り、toDestination() でそれをコンピュータのスピーカーで鳴らすよう指示します。
  • triggerAttackRelease() は、その瞬間に音を鳴らし始め (Attack)、指定された長さの後に離せ (Release) ということを意味します。Attack と Release を別々に指定することもできますが (triggerAttack()triggerRelease())、今回は使いません。
    • "C4" の部分に音名 (note) を指定します。C4 がいわゆる「真ん中のド」です。
    • "8n" の部分は音を鳴らす長さ (duration) を指定します。この場合は 8 分音符分だけ鳴らすことを意味します。

Tone.js で音を鳴らす時は毎回このような手続きを踏むことになります。


音名 (note) は Wikipedia で「科学的ピッチ」と言われている形式で指定します。

  • ドイツ語式 (C, D, E, F, G, A, H) ではなく英語式 (C, D, E, F, G, A, B) を使うことに注意してください。
  • # でシャープ、b (小文字のビー) でフラットになります。例えば "G#4" で「真ん中のソ」のシャープ、"Ab4" で「真ん中のラ」のフラットになります ("Gis4""As4" では通じません)。当然ですが、これらは同じ音が鳴ります (異名同音)。
英語では Scientific Pitch Notation と言います。(https://www.liveabout.com/pitch-notation-and-octave-naming-2701389)


音の長さ (duration) の細かい仕様は以下をご覧ください。

Time · Tonejs/Tone.js Wiki · GitHub

とりあえず当記事では以下のことが分かってれば大丈夫です。

  • "1n"全音符分, "2n" で 2 分音符分, "4n" で 4 分音符分, "8n" で 8 分音符分, "16n" で 16 分音符分, ... のように n を単位にすると「~分音符分」になります。小数のような中途半端な値でもちゃんと動きます。
  • "4n." のように末尾にドットを付けると付点 4 分音符分になります。他についても同様です。
  • "1m" で 1 小節分 (measure) になります。
  • "8t" は 4 分音符の 1/3、"16t" は 8 分音符の 1/3 です。3 連符の duration に使えます。


とりあえず現時点での HTML の例: *2

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://unpkg.com/tone"></script>
    <title>Document</title>
</head>
<body>
    <input type="button" value="Play" id="playButton">

    <script>
        document.getElementById("playButton").addEventListener("click", () => {
            const synth = new Tone.Synth().toDestination();
            synth.triggerAttackRelease("C4", "8n");
        });
    </script>
</body>
</html>


時刻の概念 - Tone.now()

さて、今度は単音ではなく旋律を作っていきたいですが、その前に Tone.js の時間の扱いについて知っておく必要があります。

Tone.js には現在時刻 (time) という概念があります。これは何かというと、ブラウザでページを読み込んだ瞬間を 0 とした時の、そこから経過した時間の秒数のことを指します。
単に synth.triggerAttackRelease("C4", "8n"); とすればその瞬間にド (C4) が 8 分音符分 (8n) だけ鳴ってくれるのですが、例えばその 1 秒後にソ (G4) の音を鳴らしたいと思った場合は

synth.triggerAttackRelease("C4", "8n");
synth.triggerAttackRelease("G4", "8n", 1);    // これではダメです!!

のように書いてはいけません。これだと、ページを読み込んだ 1 秒後にソ (G4) を鳴らせ、という意味になってしまい、(場合によっては) 過去に対する指示になってしまうのでエラーが起きます。ちゃんと「現在時刻の 1 秒後」と指示してあげる必要がある訳です。

現在時刻が欲しい場合は Tone.now() を使います。

const synth = new Tone.Synth().toDestination();
const now = Tone.now();        // 現在時刻の取得
synth.triggerAttackRelease("C4", "8n");
synth.triggerAttackRelease("G4", "8n", now + 1);    // 現在時刻の 1 秒後に鳴らせ! → ok!

これで、ド (C4) が鳴った 1 秒後にちゃんとソ (G4) が鳴ると思います。

このことを踏まえて、きらきら星の冒頭を鳴らしてみましょう。このように書けるはずです。

document.getElementById("playButton").addEventListener("click", () => {
    const synth = new Tone.Synth().toDestination();
    const now = Tone.now();
    synth.triggerAttackRelease("C4", "8n");
    synth.triggerAttackRelease("C4", "8n", now + 0.5);
    synth.triggerAttackRelease("G4", "8n", now + 1);
    synth.triggerAttackRelease("G4", "8n", now + 1.5);
    synth.triggerAttackRelease("A4", "8n", now + 2);
    synth.triggerAttackRelease("A4", "8n", now + 2.5);
    synth.triggerAttackRelease("G4", "4n", now + 3);

    synth.triggerAttackRelease("F4", "8n", now + 4);
    synth.triggerAttackRelease("F4", "8n", now + 4.5);
    synth.triggerAttackRelease("E4", "8n", now + 5);
    synth.triggerAttackRelease("E4", "8n", now + 5.5);
    synth.triggerAttackRelease("D4", "8n", now + 6);
    synth.triggerAttackRelease("D4", "8n", now + 6.5);
    synth.triggerAttackRelease("C4", "4n", now + 7);
});

最初の triggerAttackRelease() には now を指定しないでください。タッチの差で過去への指示になってしまいエラーが起きます。




現在の HTML の例:

<!DOCTYPE html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://unpkg.com/tone"></script>
    <title>Document</title>
</head>
<body>
    <input type="button" value="Play" id="playButton">

    <script>
        document.getElementById("playButton").addEventListener("click", () => {
            const synth = new Tone.Synth().toDestination();
            const now = Tone.now();
            synth.triggerAttackRelease("C4", "8n");
            synth.triggerAttackRelease("C4", "8n", now + 0.5);
            synth.triggerAttackRelease("G4", "8n", now + 1);
            synth.triggerAttackRelease("G4", "8n", now + 1.5);
            synth.triggerAttackRelease("A4", "8n", now + 2);
            synth.triggerAttackRelease("A4", "8n", now + 2.5);
            synth.triggerAttackRelease("G4", "4n", now + 3);

            synth.triggerAttackRelease("F4", "8n", now + 4);
            synth.triggerAttackRelease("F4", "8n", now + 4.5);
            synth.triggerAttackRelease("E4", "8n", now + 5);
            synth.triggerAttackRelease("E4", "8n", now + 5.5);
            synth.triggerAttackRelease("D4", "8n", now + 6);
            synth.triggerAttackRelease("D4", "8n", now + 6.5);
            synth.triggerAttackRelease("C4", "4n", now + 7);
        });
    </script>
</body>
</html>

なお、今後も JavaScript のコードを載せていくことになると思いますが、その際はこのように "playButton"addEventListener() の中に追記していくとよいかと思います。


和音 - Tone.Synth() と Tone.PolySynth()

さて、勢いに乗って今度は和音を作っていきたいところですが、同時刻を指定すればええやろってノリで

const synth = new Tone.Synth().toDestination();
synth.triggerAttackRelease("C4", "8n");    // ここではよくても
synth.triggerAttackRelease("E4", "8n");    // ここで過去の話になってしまう
synth.triggerAttackRelease("G4", "8n");    // 同上

とやるとまた過去に対する指示だってエラーになってしまいます。かといって

const synth = new Tone.Synth().toDestination();
const now = Tone.now();
synth.triggerAttackRelease("C4", "8n", now + 1);    // これで
synth.triggerAttackRelease("E4", "8n", now + 1);    // 和音に
synth.triggerAttackRelease("G4", "8n", now + 1);    // なる? (ならない)

とすれば 1 秒後に一斉に 3 音が鳴ってくれるかと言えばそうではありません。おそらく 1 音しか聞こえてこないと思います。

これは今まで使ってきた音源 Tone.Synth() が単音にしか対応していないためで、和音のための音源はちゃんと別に用意されています。その一つが Tone.PolySynth() です。

https://tonejs.github.io/docs/latest/classes/PolySynth

PolySynth では、同時に鳴らしたい音を配列で指定することができます。*3

const polySynth = new Tone.PolySynth().toDestination();
polySynth.triggerAttackRelease(["C4", "E4", "G4"], "2n");    // C Major

この例では ド、ミ、ソ、と、 C を根音とする長三和音、いわゆる C Major が鳴ると思います。

C をルートとする長三和音。C dur の I 度。C major。呼び方色々わけわかめ

3 音の音の長さ (duration) が相異なっている場合も、下のように duration を配列で指定することで対応できます。

const polySynth = new Tone.PolySynth().toDestination();
polySynth.triggerAttackRelease(["C4", "E4", "G4"], ["4n", "2n", "1n"]);    // C Major

この場合は C4 が 1 拍、E4 が 2 拍、G4 が 4 拍になります。

こんな楽譜みたことねぇ。

さて、これで和音の進行も鳴らせるようになりました。試しに C dur の T → S → D → T (カデンツ第 2 型) を鳴らしてみましょう。

const polySynth = new Tone.PolySynth().toDestination();
const now = Tone.now();

polySynth.triggerAttackRelease(["E4", "G4", "C5"], "2n");           // I
polySynth.triggerAttackRelease(["F4", "A4", "C5"], "2n", now + 1);  // IV
polySynth.triggerAttackRelease(["D4", "F4", "G4", "B4"], "2n", now + 2); // V7
polySynth.triggerAttackRelease(["E4", "G4", "C5"], "2n", now + 3);  // I



典型的な T→S→D→T のカデンツ第 2 型 (K2)。I 度の 1 転 、IV 度 、属七の 2 転 、I 度の 1 転。しかし、和音ってムズいよね。



テンポと拍 - Tone.Transport

そろそろ Tone.js の雰囲気に慣れてきたと思いますが、まだちょっと気持ち悪いですね。そうです、テンポと拍の扱いです。

今まではテンポも拍も意識せずにただ何秒後にどの音を鳴らすかで指示をしてきたわけですが、楽譜という人類の素晴らしき発明に慣れ親しんできた我々からするとこれは苦行でしかありません。苦行というか、時代に遡行 (挑戦?) しているとさえ言えます。

例えば、テンポが 80 (bpm) の 3/4 拍子の曲を鳴らそうとした場合に、8 小節目の 2 拍目の時刻 [sec] は

now + (60.0 / 80.0) * (3 * 7 + 1)

とかやってられません。ちゃんとテンポや拍を意識しつつ、小節番号や拍数をダイレクトに指定できるようになりたいものです。


この辺りの設定は、Tone.Transport から行います。

tonejs.github.io

Tone.Transport.bpm.value = 80;           // これでテンポが 80 (bpm) になります
Tone.Transport.timeSignature = [3, 4];   // これで 3/4 拍子になります

今まで黙っていて申し訳ありませんでしたが、デフォルトではテンポが 120 (bpm)、拍子は 4/4 に設定されています。

他にも、再生/停止/ポーズの操作だとか、リピートの範囲だとか、加速 (アッチェレランド) の仕方だとかの設定も Tone.Transport からできます。詳細は document へ。
しかし、Transport なんてあんまり聞かない用語ですね。日本語で何というんだろう。。。


次に小節番号ですが、これまた Time の話に戻ります。

Time · Tonejs/Tone.js Wiki · GitHub

例えばここの Wiki の Transport Time の節にある例だと

  • "4:3:2" で、4 小節分 + 4 分音符 3 個分 + 16 分音符 2 個分

つまり、 (4 拍子の曲の場合) 5 小節目の最後の 8 分に差し掛かるまでの時間を表していることになります。



"4:3:2" は 4 小節+4 分音符 3 個 + 16 分音符 2 個分の長さです。now にこの値を足すと矢印の部分における時刻を表すことになります。

実際に triggerAttackRelease() で音を鳴らす時刻を指定する時は now にこの値を足す必要がありますが、now + "4:3:2" だと文字列の結合扱いされて値がおかしくなってしまうので、次のように "4:3:2" を秒数に変換した後加える必要があります。

now + Tone.Time("4:3:2").toSeconds()

今までの話をまとめてみましょう。
前節のカデンツをテンポ 80 (bpm) の 3/4 拍子の曲だとして 1 拍ずつ鳴らしてみると次のようなコードになります。

const polySynth = new Tone.PolySynth().toDestination();
const now = Tone.now();    // 現在時刻

Tone.Transport.bpm.value = 80;           // これでテンポが 80 (bpm) になります
Tone.Transport.timeSignature = [3, 4];   // これで 3/4 拍子になります

polySynth.triggerAttackRelease(["E4", "G4", "C5"], "4n");       // I
polySynth.triggerAttackRelease(["F4", "A4", "C5"], "4n", now + Tone.Time("0:1:0").toSeconds());  // IV
polySynth.triggerAttackRelease(["D4", "F4", "G4", "B4"], "4n", now + Tone.Time("0:2:0").toSeconds()); // V7
polySynth.triggerAttackRelease(["E4", "G4", "C5"], "2n", now + Tone.Time("1:0:0").toSeconds());  // I
拍子とテンポが明確になりました。


音と長さとタイミングを詳細に指定 - Tone.Part()

だんだんと西洋音楽らしくなってきましたね (←)。

しかし、音を鳴らすたびに triggerAttackRelease() を毎回呼ぶのは少々まどろっこしいです。加えて、せっかく小節番号や拍数でタイミングを指定できるようになったのに、now + Tone.Time().ToSeconds() をかませないといけないのもあまり賢くはなさそうですね。

音を鳴らすタイミング、音名、音の長さが統一的に管理された楽譜のようなものを外部に置いておくことができるのであれば、可読性や保守などの面でも嬉しいところです。

Tone.Part() を使えばそれを実現できます。

Part | Tone.js


とりあえず、先ほどのカデンツTone.Part() 用に書き換えたコードを見てみましょう。

const polySynth = new Tone.PolySynth().toDestination();
Tone.Transport.bpm.value = 80;           // テンポを 80 bpm にする
Tone.Transport.timeSignature = [3, 4];   // 3/4 拍子にする

// 楽譜
const score = [
    {"time": "0:0:0", "note": ["E4", "G4", "C5"], "duration": "4n"},
    {"time": "0:1:0", "note": ["F4", "A4", "C5"], "duration": "4n"},
    {"time": "0:2:0", "note": ["D4", "F4", "G4", "B4"], "duration": "4n"},
    {"time": "1:0:0", "note": ["E4", "G4", "C5"], "duration": "2n"}
];

// パート
const part = new Tone.Part((time, note) => {
    polySynth.triggerAttackRelease(note.note, note.duration, time);
}, score).start();

Tone.Transport.start();    // 音を鳴らすのに必須

今までのとだいぶ雰囲気が変わりましたが、幾許かはすっきり見え、、なくはないとは言えなくもないのかな。少なくとも、楽譜は音を鳴らす部分から独立できてはいます。nowTone.Time().toSeconds() を足すみたいな煩わしい操作もなくなっています。

しかし Part 周辺が何だかよくわかりませんね。解説します。

  • Tone.Part のコンストラクタは Tone.Part(【関数】, 【楽譜】).start(); のように使用します。
    • Part くんが 【楽譜】 を parse してくれて、そこから鳴らすべき時刻 (time) と音の情報 (note) を抽出して順々に 【関数】 の引数に渡していってくれます。私たちはそれら time, note【関数】 の中で自由に使うことができます。なお、引数名は別に timenote でなくても良いですが、【楽譜】 を辞書式に定義する場合は時刻の情報を "time" で指定する必要があります (→ Part | Tone.js) 。
    • 【楽譜】 は時刻を表す "time" キーさえあればあとはどんな key-value の組があっても良いです。 名前も型も要素数も特に制限がありません。ただ、triggerAttackRelease() の仮引数の名前が notes, duration, time (, velocity) になっているのでそれに合わせているだけです (→ PolySynth | Tone.js)。複数形の s がないじゃんとか言わない。
  • Part を定義しただけでは音は鳴りません。実際に音を鳴らすには Tone.Transport.start(); を置いておく必要があります。

まぁうだうだ説明しましたが、結局は使いながら慣れていくものだと思います。


Part はその名の通りパートの役割を担うことができます。例えばピアノソロ曲の場合、右手用のパートと左手用のパートを別々に作り、それらを同時に再生、といったことにも対応できます。*4

せっかくなので有名曲の冒頭でその挙動を確認してみましょう。モーツァルトソナタ No.16 K.545 から。

const polySynth = new Tone.PolySynth().toDestination();
Tone.Transport.bpm.value = 152;         // テンポを 152 bpm にする
Tone.Transport.timeSignature = [4, 4];  // 4/4 拍子にする (デフォルトでなるので指定不要)

// 右手の楽譜
const rightHandScore = [
    {"time": "0:0:0", "note": "C5", "duration": "2n"},
    {"time": "0:2:0", "note": "E5", "duration": "4n"},
    {"time": "0:3:0", "note": "G5", "duration": "4n"},

    {"time": "1:0:0", "note": "B4", "duration": "4n."},
    {"time": "1:1:2", "note": "C5", "duration": "16n"},
    {"time": "1:1:3", "note": "D5", "duration": "16n"},
    {"time": "1:2:0", "note": "C5", "duration": "4n"}
];

// 左手の楽譜
const leftHandScore = [
    {"time": "0:0:0", "note": "C4", "duration": "8n"},
    {"time": "0:0:2", "note": "G4", "duration": "8n"},
    {"time": "0:1:0", "note": "E4", "duration": "8n"},
    {"time": "0:1:2", "note": "G4", "duration": "8n"},
    {"time": "0:2:0", "note": "C4", "duration": "8n"},
    {"time": "0:2:2", "note": "G4", "duration": "8n"},
    {"time": "0:3:0", "note": "E4", "duration": "8n"},
    {"time": "0:3:2", "note": "G4", "duration": "8n"},

    {"time": "1:0:0", "note": "D4", "duration": "8n"},
    {"time": "1:0:2", "note": "G4", "duration": "8n"},
    {"time": "1:1:0", "note": "F4", "duration": "8n"},
    {"time": "1:1:2", "note": "G4", "duration": "8n"},
    {"time": "1:2:0", "note": "C4", "duration": "8n"},
    {"time": "1:2:2", "note": "G4", "duration": "8n"},
    {"time": "1:3:0", "note": "E4", "duration": "8n"},
    {"time": "1:3:2", "note": "G4", "duration": "8n"}
];

// 右手のパート
const rightPart = new Tone.Part((time, note) => {
    polySynth.triggerAttackRelease(note.note, note.duration, time);
}, rightHandScore).start();

// 左手のパート
const leftPart = new Tone.Part((time, note) => {
    polySynth.triggerAttackRelease(note.note, note.duration, time);
}, leftHandScore).start();

Tone.Transport.start();

本来のテンポ指定は Allegro ですが、152 bpm 指定の楽譜が多かったのでそうしました。スラーの再現まではできてませんがノリで載せました。

IMSLP:
Piano Sonata No.16 in C major, K.545 (Mozart, Wolfgang Amadeus) - IMSLP


もうだいぶ楽譜の構造をそのままコードに起こしているような感じになってきましたね。

【番外編】 少し特殊な Tone.Sequence()

スコアを parse するツールには他に Tone.Sequence() というものがあります。
Tone.Part() では時刻の情報をコンストラクタ第二引数の 【楽譜】 から抽出していましたが、こちらの場合は、自動で 1 拍ずつ刻むようになっています。

まぁ、具体例を見るのが早いと思います。ピアノ曲ではないですが、グリーグの『朝』。

const polySynth = new Tone.PolySynth().toDestination();
Tone.Transport.bpm.value = 90;          // テンポを 90 bpm にする
Tone.Transport.timeSignature = [6, 8];  // 6/8 拍子にする

// 右手の楽譜
const rightHandScoreSeq = [
    {"note": "B5", "duration": "8n"}, 
    {"note": "G#5", "duration": "8n"}, 
    {"note": "F#5", "duration": "8n"}, 
    {"note": "E5", "duration": "8n"}, 
    {"note": "F#5", "duration": "8n"}, 
    {"note": "G#5", "duration": "8n"}, 

    {"note": "B5", "duration": "8n"}, 
    [{"note": "G#5", "duration": "16t"}, {"note": "A5", "duration": "16t"}, {"note": "G#5", "duration": "16t"}], 
    {"note": "F#5", "duration": "8n"}, 
    {"note": "E5", "duration": "8n"}, 
    [{"note": "F#5", "duration": "16n"}, {"note": "G#5", "duration": "16n"}],
    [{"note": "F#5", "duration": "16n"}, {"note": "G#5", "duration": "16n"}]
];

// 左手の楽譜
const leftHandScoreSeq = [
    {"note": ["E4", "B4"], "duration": "2n."},
    null,
    null,
    null,
    null,
    null,

    {"note": ["E4", "B4"], "duration": "2n."},
    null,
    null,
    null,
    null,
    null,
];

// 右手の sequence
const rightSequence = new Tone.Sequence((time, note) => {
    polySynth.triggerAttackRelease(note.note, note.duration, time);
}, rightHandScoreSeq).start();
rightSequence.loop = 1;      // sequence のループ回数

// 左手の sequence
const leftSequence = new Tone.Sequence((time, note) => {
    polySynth.triggerAttackRelease(note.note, note.duration, time);
}, leftHandScoreSeq).start();
leftSequence.loop = 1;       // sequence のループ回数

Tone.Transport.start();

この楽譜を Tone.Sequence() を使って再現しました。3 連符のところは本来は装飾音符 (複前打音) なので、実際はこんなに長くないです。

IMSLP:
Peer Gynt Suite No.1, Op.46 (Grieg, Edvard) - IMSLP

使い方自体は Tone.Part() とほとんど一緒ですが、音を鳴らすタイミングは自動で 1 拍ずつ刻む仕様になっているので、何もすることがない場所では null を指定する必要があります。逆に同じ場所に複数の辞書がある場合はその数だけの連符になります。また、 1 つの辞書に対して複数の note がある場合は和音になります。

辞書の配列にするか、値が配列になっている辞書にするかで、連符か和音かが変わるので、混同しないように注意しましょう。

Tone.Part() のように音を鳴らす時刻を直接指定するわけではなく、拍の数だけ辞書を用意しておく必要があるので少し奇妙に思われるかもしれません。実際私も何がしたいんだ状態でした。しかし、休符を明示的に書くことでリズム感覚が明確になったり、連符が簡単に書けたりと、それなりにメリットもあります。
Tone.Part() とどちらを使うかは個人の趣味の範疇になるでしょう。拍や休符の感覚を大事にしたいという方であれば Tone.Part() よりもこちらの方が肌に合っているかもしれませんね。

音源を任意に指定する - Tone.Sampler()

さあ、Tone.Part()Tone.Sequence() を習得したあなたは既にあらゆる曲を奏でられるようになったはずです *5。ここまでお疲れさまでした。

最後にやりたいのは、今まで流し続けてきた電子音源 Tone.PolySynth() をピアノ音源に変えることですね。

幸運なことに、そのためのツールとピアノ音源は既に公式によって用意されています。

Sampler | Tone.js
Sampler
audio/salamander at master · Tonejs/audio · GitHub

書くべきことは決まっています。何も考えずにあなたのコードに以下を導入しましょう。

const piano = new Tone.Sampler({
    urls: {
        A0: "A0.mp3",
        C1: "C1.mp3",
        "D#1": "Ds1.mp3",
        "F#1": "Fs1.mp3",
        A1: "A1.mp3",
        C2: "C2.mp3",
        "D#2": "Ds2.mp3",
        "F#2": "Fs2.mp3",
        A2: "A2.mp3",
        C3: "C3.mp3",
        "D#3": "Ds3.mp3",
        "F#3": "Fs3.mp3",
        A3: "A3.mp3",
        C4: "C4.mp3",
        "D#4": "Ds4.mp3",
        "F#4": "Fs4.mp3",
        A4: "A4.mp3",
        C5: "C5.mp3",
        "D#5": "Ds5.mp3",
        "F#5": "Fs5.mp3",
        A5: "A5.mp3",
        C6: "C6.mp3",
        "D#6": "Ds6.mp3",
        "F#6": "Fs6.mp3",
        A6: "A6.mp3",
        C7: "C7.mp3",
        "D#7": "Ds7.mp3",
        "F#7": "Fs7.mp3",
        A7: "A7.mp3",
        C8: "C8.mp3",
    },
    baseUrl: "https://tonejs.github.io/audio/salamander/", 
    onload: () => {
        // ここに音源 piano に対する処理を書く
    }
}).toDestination();
  • いくつかの音を mp3 ファイルに直接関連付けさせます。実際のピアノのように 88 音すべてに対して指定する必要はなく、指定されていない音に関しては Tone.Sampler() 君が自動で補完してくれます。

実用の上では、onload の中にいま作った音源 piano に対する処理を書いていきます。イメージとしては、今まで書いてきたコードの polySynthpiano に書き換えてそこに貼り付けるといった感じです。


先ほどのモーツァルト K.545 の場合は次のような感じでしょうか。*6

github.com

楽譜の部分は playButton を押す前から定義されていて良いので、addEventListener() の外側に置きました。

https://bakkyalo.github.io/mypage/mozart-k545.html

↑ ここから実演を確認できます。左上の "Play" ボタンを押すとちゃんとピアノ音源でモーツァルトが鳴るのが確認できるかと思います。

これから何が待っているか

さて、当記事での Tone.js の tutorial はこれで終了ですが、私が今回作ったコードはこれですべてと言っても過言ではありません。
基本的に、Tone.Part() を使ってゴリ押しているだけなので、この段階で私の ゴミコードを眺めても何をやっているのかは大体分かると思います。

そのため、ここではこれまでのチュートリアルで実際にモーツァルトの K.545 の冒頭 2 小節をピアノ音源で弾けるようになった状態から冒頭の動画のようなピアノの自動演奏を実装していくにあたって、今後何をしていけばよいのか、またどのような課題があるのかについてちょっと話して、この記事を締めたいと思います。

譜面の打ち込み

Tone.Part() を使えばあらゆる譜面を再現できることが分かったので、あとは自分が鳴らしたい好きな曲を譜面に興す作業になります。

ここからはただひたすらえっちらおっちら手打ちしていくことになります。こんな感じに。

github.com

なんとアナログな...
改行がそれなりにあるとはいえ、たった 14 小節で 400 行近くなるのですから大変です。しかも全部手打ち。自分が今まで打ってきた譜面が本当に正しいかどうかを確認するにも、最初から再生させてみるくらいしかない上に自分の耳を信じるしかないのでなかなか不毛な作業時間です (だが楽しい)。デバッグ用にポーズ機能、途中から再生機能を作った方が良さそう。楽譜にしてくれるのが一番嬉しいけどね。

Play Mondschein3 | あかり描像のページ
↑ 実演はこちらで確認できます。*7


※IMSLP:
Piano Sonata No.14, Op.27 No.2 (Beethoven, Ludwig van) - IMSLP

譜面を外部に置いてそれを読む - fetch API

譜面を外部ファイルに置いてそれを読み込む実装の説明もまだしていませんでしたが、これは Tone.js とは関係なく、一般的な JavaScript の話なので、他の詳しいところを参照された方が良いかと思います。実際、私もよく分かってないです。ですがまぁ、ちょっとだけその辺について語らせてください。

外部に譜面を .json ファイルとして置き、それを fetch API を使って読み込ませる方針で行きます。

XMLHttpRequest() というのもありますが、かなり古い書き方だそうなので今回は fetch API で実装しました。
Node.js に載せていたら fs モジュールが使えるのでそっちの方が便利かもしれません。

とはいえ、書き方は決まっています。

fetch("./path/to/score.json")
    .then( response => {
        if(!response.ok) {
            throw new Error(`Network response was not ok: ${response.status}`);
        }
        return response.json();
    })
    .then( data => {
        // ここに data (json) を使った処理を書く
    })
    .catch( error => {
        console.error("Fetch Error: ", error);
    });

さらに、HTML 上に演奏する楽譜の .json を選択するプルダウンメニューを設けたり、ユーザーのローカルファイルの入力を受け入れるフォームを設けたりするとさらに使いやすいアプリになりそうですね。

MIDI ファイルの扱い

直前の fetch API の話も含め、今回は Tone.Part() を使うという基本方針で設計をしましたが、ひとつ難点があります。楽譜の .json ファイルの中身が独自規格になってしまうという点です。

今回は最低限旋律を流せれば良いという事で、Tone.Part() の仕様にかなり合わせた形の楽譜になりましたが、こんな形式を使っている人は世界中に誰もいません。「楽譜ファイル作ったよ」と言って誰かに配布しても、それを再生できる環境が私のページとこの記事に載っているコードを使う一部の物好きくらいしかないので、もんのすごいガラパゴスになります。
また、途中でテンポを変える機能だとか、リピートを再現するだとか、さまざまな機能を付けていこうと考えると、再度設計し直しになり、場合によっては今までの独自 .json が読めないようになってしまう可能性すらあります。

コンピュータ上で音楽データを扱う際に広く一般的に使われている形式はおそらく MIDI でしょう。.midi を使う方針であれば、再生も編集も誰でもできるので、使ってくれる人がそれなりに出てくる、かもしれません。
ユーザーもどこぞのいかがわしいゲーム三昧オタクが作った独自の .json 形式なんかよりも .midi を使いたいと思うはずです。

実は Tone.js には MIDI ファイルを取り扱う API が用意されています。

Midi
GitHub - Tonejs/Midi: Convert MIDI into Tone.js-friendly JSON

さらに、MIDI ファイルを読み込んで Tone.js でそれを再生する example もすでにできています。

https://tonejs.github.io/Midi/

何というか、この記事でやってきたことが果たして意味があったのかというくらい普遍的かつ強力なツールですね。

今後、Tone.js を使った Web アプリの開発をすることがもしあるとするなら、少なくとも MIDI くらいは扱うことができるような設計、あるいは計画を意識していかないといけないでしょうね。

鍵盤 - CSS との闘い

Web ブラウザからピアノが鳴るだけでも感動ものではありますが、どうせなら鍵盤も一緒に表示させて自動演奏の様子を見せられたら面白いです。

しかし、鍵盤の描画はどうも CSS でガチるしかなさそうです。

鍵盤の実装は世界中のいろいろなサイトで公開されていますが、定番の API を使う、といった感じではなく、オリジナルの CSS をみんなバラバラに自作していっているような傾向がある印象です *8。 HTML のどのような要素を作ってどのような class を割り振るかといった話は各々の設計思想にかなり左右されるところです。

そして鍵盤の配置も単純なようで意外と難しいです。
CSS を組む以上、鍵盤の構造についての理解をする必要があるのですが、これがなかなかに沼です。数学的な諸事情によって寸法が定まっていないのです。このあたりの話もまた面白いのですが、話が脱線しそう (だし、プログラム合わせて 3 万文字とかになってなかなか重たくなってきた) なのでまた別の機会で書きたいと思っています。

興味のある方は、
鍵盤の配置 · Issue #3 · bakkyalo/mypage · GitHub
↑ ここに雑に貼ったリンク先などを見てみると良いかと思います。


mypage/piano-88keys.html at master · bakkyalo/mypage · GitHub
mypage/css/piano-88keys.scss at master · bakkyalo/mypage · GitHub

私の格闘の様子は ↑ ここで確認できます (逐次更新しています)。
とりあえず妥協に妥協を重ねたうえでとにかく動くものを作ろうという方針で作っています。

tone-ui.js を流用する

いちおう Tone.js 公式に鍵盤を表示するそれっぽいものがあります。

Sampler

このページのように、tone-ui.js というものを読み込むと鍵盤を触れるようになりますが、私がいまいちその API の仕様を理解していないのでそれを使った実装とは程遠い状態です。これに自動演奏させる方法のほの字も分からないです。情報がある方は教えて頂けると嬉しいです。

以下、自分用メモ。

tone-ui.js を読み込むと、"content" という id の div を配置するだけでそこにキーボードが現れる。

<head>
    <script src="https://tonejs.github.io/build/Tone.js"></script>
    <script src="https://tonejs.github.io/examples/js/tone-ui.js"></script>
    <script src="https://tonejs.github.io/examples/js/components.js"></script>
</head>

<body>
    <div id="content"></div>
</body>

そして以下を body の中にコピペするとピアノの音が鳴るようになる。

<script type="text/javascript">
	const sampler = new Tone.Sampler({
		urls: {
			A0: "A0.mp3",
			C1: "C1.mp3",
			"D#1": "Ds1.mp3",
			"F#1": "Fs1.mp3",
			A1: "A1.mp3",
			C2: "C2.mp3",
			"D#2": "Ds2.mp3",
			"F#2": "Fs2.mp3",
			A2: "A2.mp3",
			C3: "C3.mp3",
			"D#3": "Ds3.mp3",
			"F#3": "Fs3.mp3",
			A3: "A3.mp3",
			C4: "C4.mp3",
			"D#4": "Ds4.mp3",
			"F#4": "Fs4.mp3",
			A4: "A4.mp3",
			C5: "C5.mp3",
			"D#5": "Ds5.mp3",
			"F#5": "Fs5.mp3",
			A5: "A5.mp3",
			C6: "C6.mp3",
			"D#6": "Ds6.mp3",
			"F#6": "Fs6.mp3",
			A6: "A6.mp3",
			C7: "C7.mp3",
			"D#7": "Ds7.mp3",
			"F#7": "Fs7.mp3",
			A7: "A7.mp3",
			C8: "C8.mp3",
		},
		release: 1,
		baseUrl: "https://tonejs.github.io/audio/salamander/",
	}).toDestination();
	piano({
		parent: document.querySelector("#content"),
		noteon: (note) => sampler.triggerAttack(note.name),
		noteoff: (note) => sampler.triggerRelease(note.name),
	});
</script>

参考:
https://github.com/Tonejs/tonejs.github.io/blob/master/examples/sampler.html
https://tonejs.github.io/examples/sampler

おわりに

Web 上で音を鳴らすツールに Tone.js というものがあることを知って、果たしてどこまでできるんだろうという興味本位でいろいろとやってみました。
結論から言うと、どこまでも行けそうな雰囲気があります。今回は Tone.Part() を使って独自形式のフォーマットから自動演奏をするところまで来ましたが、

  • 譜面を編集するエディタを作る
  • より一般的な音楽形式に対応する
  • 再生時のエフェクトを鍵盤に限らずかっこいい幾何学模様などで表現する

などなど、色々な方向性がある上にどれもこれも実現可能性がそれなりに高いように思います。
これはひとえに JavaScript が強力かつ扱いやすいという側面が大きいのかもしれません。

ただ、作ったところで何だって話ではあります。すでに類似のアプリはたくさんある訳で、新たにそれに似たアプリを作ったところでただの自己満足にしかなりません。まぁ、趣味ってそんなもんだから別にいいか。そんなこと言ったら人生が無駄だもんな、まったく。

ここまで読んでいただきありがとうございました。チャンネル登録お願いします

*1:必ずしも末尾でなくても良いですが、少なくとも "playButton" が見つかる状況にはしてください。

*2:VSCode で HTML 拡張を入れると、"!" と打つだけでこのようなスニペットが貼られます。

*3:1 音ずつ指定することもできます。ただし、その場合は例の如く過去への指示にならないように注意してください。

*4:当然ですが、Synth のように同時に音を鳴らすのに対応していない音源ではできないです。

*5:一応、途中でテンポを変えるとか反復記号をどう反映させるかの話がまだありますが... 今回はそこまでやりません。document をご参照ください。

*6:すみません、ブログにコードを直接貼ると重すぎてなかなか保存できなくなるので GitHub へのリンクに留めます。

*7:Stop ボタンの実装ができてませんが、そのうち書きます

*8:覇権とるならここか!?という訳にもいかないでしょう。誰でも使える API としてはあんまり普及していないですが、すでに類似のソフトはごまんとあります。ブラウザ上で鍵盤を表示しつつ自動演奏するアプリとしてはそれこそ MuseScore があります。




以上の内容はhttps://bakkyalo.hatenablog.jp/entry/2024/06/01/043217より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14