Web Audio APIとRxJSでブラウザのピアノキーボードを作ろう！

ハイサイ！オースティンやいびーん！

# 概要

Web Audio APIでピアノを作ります。

ただのピアノじゃ面白くないので、RxJSでイベントハンドリングをして最高にFunctional Programming向けのキーボードにします！

## Web Audio APIとは

[Web Audio API](https://developer.mozilla.org/ja/docs/Web/API/Web_Audio_API)は、ブラウザの標準技術で完全にカスタムな音響システムを提供してくれるAPIです。

上記のMDN記事では「多機能」と描写していますが、それは非常に謙虚な言葉です。**これはまるで電子基盤を好きなように作らせてくれるほど、強力で充実したオーディオ基盤です**。筆者はWeb Audio APIの一割でも理解しているというフリさえできません。奥が深く、レベルが高いAPIです。数学の知恵も要求されるし、音響物理の知識も必要になります。

筆者は物理学部だったのですが、カスタムなシンスを作るのはお手上げです。:sweat_smile:

## RxJSとは

RxJSは非同期処理とイベントのハンドリング処理を簡単にしてくれるライブラリです。Promiseよりもずっと強力な非同期処理のツールです。

非同期処理じゃなくても大量のイベントの受診を捌いてパフォーマンスが悪くならないようにする手段をいろいろ用意してくれています。

RxJSは古くからあるReactive ProgrammingをJavaScriptでもできるようにMicrosoftが作ってくれたライブラリです。Reactive ProgrammingとFunctional Programmingが注目されている今では、RxJSも

# プロジェクトセットアップ

今回はWeb Componentsでキーボードを包括したいので[LitElement](https://lit.dev/) + Viteを使います。

ついでにRxJSもインストールしましょう。

```
yarn create vite wc-keyboard --template lit-ts
cd wc-keyboard
yarn add rxjs
```

# キーボードのイベントを受診します

Viteが生成してくれた部品のCSSだけを残して他を全部削除しましょう。

```typescript
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のオブジェクトをゴミ箱に捨てられるようにします。

```typescript
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`イベントを受診して、ユーザーが今どのキーを推しているのかを把握します。

```typescript
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`からです！

```typescript
  private audioCtx = new AudioContext();
```

音量も設定したいのでGainNodeをついでに作ります。また、この`GainNode`にあとでピアノの音を繋ぐのですが、`GainNode`がスピーカーから出るように`connect`を呼ぶ必要があります。

```typescript
  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まで取り込みます。とても低いですがちゃんと出ますよ！

そして以下のリンクに入っている数式でキー番号を使って音の周期を計算します。

https://en.wikipedia.org/wiki/Piano_key_frequencies

JavaScriptで表現すると以下になります。JavaScriptで12根の計算ができないので母数にして1/12乗で計算できます。

```typescript
  private getKeyFreq(n: number): number {
    return 2 ** ((n - 49) / 12) * 440;
  }
```

そして物理キーボードのキーとピアノのキーのマッピングは以下のようにします。

```typescript
  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のストリームに落とすと以下のようになります。

```typescript
  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を鳴らせる関数、止める関数

鳴らせる関数と、止める関数を用意します。

```typescript
  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を鳴らしたり止めたりします。

```typescript
    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);
        }
      });
    });
```

これで鳴ります！！

ビジュアルを追加するともっとすごいんですが、別の記事にします:smirk:

バリバリ作ったやつを以下で試してみてください。

https://tronicboy1.github.io/wc-virtual-keyboard/

<iframe src="https://tronicboy1.github.io/wc-virtual-keyboard/"></iframe>

# まとめ

本の少しでしたが、Web Audio APIを使ってみていかがでしょうか？

他の記事で音の視覚化についても触れますのでお楽しみに！今は晩御飯を！:spaghetti: