ハイサイ!オースティンやいびーん!
概要
Web Audio APIでピアノを作ります。
ただのピアノじゃ面白くないので、RxJSでイベントハンドリングをして最高にFunctional Programming向けのキーボードにします!
Web Audio APIとは
Web Audio APIは、ブラウザの標準技術で完全にカスタムな音響システムを提供してくれるAPIです。
上記のMDN記事では「多機能」と描写していますが、それは非常に謙虚な言葉です。これはまるで電子基盤を好きなように作らせてくれるほど、強力で充実したオーディオ基盤です。筆者はWeb Audio APIの一割でも理解しているというフリさえできません。奥が深く、レベルが高いAPIです。数学の知恵も要求されるし、音響物理の知識も必要になります。
筆者は物理学部だったのですが、カスタムなシンスを作るのはお手上げです。
RxJSとは
RxJSは非同期処理とイベントのハンドリング処理を簡単にしてくれるライブラリです。Promiseよりもずっと強力な非同期処理のツールです。
非同期処理じゃなくても大量のイベントの受診を捌いてパフォーマンスが悪くならないようにする手段をいろいろ用意してくれています。
RxJSは古くからあるReactive ProgrammingをJavaScriptでもできるようにMicrosoftが作ってくれたライブラリです。Reactive ProgrammingとFunctional Programmingが注目されている今では、RxJSも
プロジェクトセットアップ
今回はWeb Componentsでキーボードを包括したいのでLitElement + Viteを使います。
ついでにRxJSもインストールしましょう。
yarn create vite wc-keyboard --template lit-ts
cd wc-keyboard
yarn add rxjs
キーボードのイベントを受診します
Viteが生成してくれた部品のCSSだけを残して他を全部削除しましょう。
import { LitElement, css, html } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("my-element")
export class MyElement extends LitElement {
render() {
return html``;
}
static styles = css`
...
`;
}
declare global {
interface HTMLElementTagNameMap {
"my-element": MyElement;
}
}
teardown$でLit Elementが消えたらRxJSの後片付けができるようにする
LitElementの中であれこれRxJSを使いますが、メモリリークにならないようにLitElementが消えたらRxJSのオブジェクトをゴミ箱に捨てられるようにします。
import { LitElement, css, html } from "lit";
import { customElement } from "lit/decorators.js";
import { Subject } from "rxjs";
@customElement("my-element")
export class MyElement extends LitElement {
private teardown$ = new Subject<void>();
disconnectedCallback(): void {
super.disconnectedCallback();
this.teardown$.next();
}
render() {
return html``;
}
...
このteardown$
をRxJS OperatorのtakeUntil
をセットで使うとメモリリークを起こさずにすみます。
document
からキーボードイベントを受診する
RxJSのfromEvent
でkeydown
とkeyup
イベントを受診して、ユーザーが今どのキーを推しているのかを把握します。
import { LitElement, css, html } from "lit";
import { customElement } from "lit/decorators.js";
import { Subject, distinctUntilChanged, fromEvent, map, merge, scan, takeUntil } from "rxjs";
enum Action {
Add = 1,
Delete,
}
@customElement("my-element")
export class MyElement extends LitElement {
private teardown$ = new Subject<void>();
private physicalKeydown$ = fromEvent<KeyboardEvent>(document, "keydown").pipe(
takeUntil(this.teardown$),
map(({ key }) => key)
);
private physicalKeyup$ = fromEvent<KeyboardEvent>(document, "keyup").pipe(
takeUntil(this.teardown$),
map(({ key }) => key)
);
private physicalKeysDown$ = merge(
this.physicalKeydown$.pipe(map((key) => [Action.Add, key] as const)),
this.physicalKeyup$.pipe(map((key) => [Action.Delete, key] as const))
).pipe(
distinctUntilChanged((a, b) => a[0] === b[0] && a[1] === b[1]),
scan((acc, [action, key]) => {
switch (action) {
case Action.Delete:
acc.delete(key);
break;
case Action.Add:
acc.add(key);
break;
}
return acc;
}, new Set<string>())
);
...
これでユーザーが推しているキーが全部わかります。
Web Audio APIのセットアップ
次、Web Audio APIで音を流せるように設定をします。
まずはAudioContext
からです!
private audioCtx = new AudioContext();
音量も設定したいのでGainNodeをついでに作ります。また、このGainNode
にあとでピアノの音を繋ぐのですが、GainNode
がスピーカーから出るようにconnect
を呼ぶ必要があります。
private audioCtx = new AudioContext();
private gainNode = this.audioCtx.createGain();
constructor() {
super();
this.gainNode.gain.value = 0.5;
this.gainNode.connect(this.audioCtx.destination);
}
キーのマッピングを設定する
キーを押したら音の周期になるようにマッピングがしたいです。今回、決まった周期を使いますが、ピアノの4番目のキーのC1からC2まで取り込みます。とても低いですがちゃんと出ますよ!
そして以下のリンクに入っている数式でキー番号を使って音の周期を計算します。
JavaScriptで表現すると以下になります。JavaScriptで12根の計算ができないので母数にして1/12乗で計算できます。
private getKeyFreq(n: number): number {
return 2 ** ((n - 49) / 12) * 440;
}
そして物理キーボードのキーとピアノのキーのマッピングは以下のようにします。
private keyMappings = new Map([
["a", 4],
["w", 5],
["s", 6],
["e", 7],
["d", 8],
["f", 9],
["t", 10],
["g", 11],
["y", 12],
["h", 13],
["u", 14],
["j", 15],
["k", 16],
]);
RxJSのストリームに落とすと以下のようになります。
private keyMappings = new Map([
["a", 4],
...
["k", 16],
]);
private notesPressed$ = this.physicalKeysDown$.pipe(
map((keysPressed) => {
const notesPressed = new Set<number>();
keysPressed.forEach((key) => {
const pianoKeyNo = this.keyMappings.get(key);
if (pianoKeyNo) {
const hz = this.getKeyFreq(pianoKeyNo);
notesPressed.add(hz);
}
});
return notesPressed;
})
);
押しているキーをOscillatorNodeに変換する
あと少しで音が出せます!上記のマッピングで音の周期に変換したキーをさらにWeb Audio APIのOscillatorNode
に変換すれば鳴ります。
OscillatorNodeを鳴らせる関数、止める関数
鳴らせる関数と、止める関数を用意します。
private activeNotes = new Map<number, OscillatorNode>();
private startNote(hz: number) {
if (this.activeNotes.has(hz)) return;
const osc = this.audioCtx.createOscillator();
osc.connect(this.gainNode);
osc.frequency.value = hz;
osc.start();
this.activeNotes.set(hz, osc);
}
private stopNote(hz: number) {
if (!this.activeNotes.has(hz)) return;
const osc = this.activeNotes.get(hz)!;
osc.stop();
this.activeNotes.delete(hz);
}
鳴らしている音のキャッシュも用意して、OscillatorNodeを止めてから削除するようにします。
notesPressed$
で鳴らす
最後に、notesPressed$
をRxJSで購読してOscillatorNodeを鳴らしたり止めたりします。
super();
this.gainNode.gain.value = 0.5;
this.gainNode.connect(this.audioCtx.destination);
this.notesPressed$.subscribe((notesPressed) => {
notesPressed.forEach((hz) => {
this.startNote(hz);
});
this.activeNotes.forEach((_, hz) => {
if (!notesPressed.has(hz)) {
this.stopNote(hz);
}
});
});
これで鳴ります!!
ビジュアルを追加するともっとすごいんですが、別の記事にします
バリバリ作ったやつを以下で試してみてください。
まとめ
本の少しでしたが、Web Audio APIを使ってみていかがでしょうか?
他の記事で音の視覚化についても触れますのでお楽しみに!今は晩御飯を!