ReactでSine波を再生するCodePenを作った

# 経緯
ギターのチューニングをするときに440HzのSine波が手軽に再生できたら便利だと思ったから。
どうせならスマホとかブラウザでいつでもSine波が再生できるやつをTypeScriptでReactで作ってみた。

# こんなんできましたけど

<p data-height="512" data-theme-id="0" data-slug-hash="eYVzyQO" data-default-tab="result" data-user="nishidemasami" data-embed-version="2" data-pen-title="eYVzyQO" class="codepen">See the Pen <a href="https://codepen.io/nishidemasami/pen/eYVzyQO/">eYVzyQO</a> by Masami,Nishide (<a href="https://codepen.io/nishidemasami">@nishidemasami</a>) on <a href="https://codepen.io">CodePen</a>.</p>
<script async src="https://production-assets.codepen.io/assets/embed/ei.js"></script>

# こだわり
`React`だけじゃなく、テキストフィールドやボタンの見た目も良くするために`MUI`も使った。
本当は`Redux-toolkit`も使いたかったけど、今回は`useStateHook`の中に全部ぶち込んだ。

# コード全文

```typescript
// TypeScriptのためのwindow.webkitAudioContextの型定義
declare global {
  interface Window {
    AudioContext: typeof AudioContext;
    webkitAudioContext: typeof AudioContext;
  }
}

type State = {
  /** 再生中かどうかフラグ */
  playing: boolean;
  /** PLAY/STOPボタンonClickイベント */
  onClickPlayStopButton: () => void;
  /** 周波数 */
  frequency: string;
  /** 周波数テキストフィールドonChangeイベント */
  onChangeFrequencyTextField: (e: React.ChangeEvent<HTMLInputElement>) => void;
  /** エラーフラグ */
  isError: boolean;
};

const useStateHook: () => State = () => {
  // Web Audio API AudioContextインスタンス
  const [audioContext] = React.useState<AudioContext>(
    new (window.AudioContext || window.webkitAudioContext)()
  );
  // Web Audio API OscillatorNodeインタフェース
  const oscillator = React.useRef<OscillatorNode>();

  // 再生中かどうかフラグ ※初期値:false
  const [playing, setPlayingFlag] = React.useState(false);
  // 周波数 ※初期値:440Hz
  const [frequency, setFrequency] = React.useState(String(440));
  // エラーフラグ ※初期値:false
  const [isError, setIsError] = React.useState(false);

  // PLAY/STOPボタンonClickイベント
  const onClickPlayStopButton = React.useCallback(() => {
    setPlayingFlag(!playing);
  }, [playing]);

  // 周波数テキストフィールドonChangeイベント
  const onChangeFrequencyTextField = React.useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setFrequency(e.target.value);
    },
    []
  );

  // エラー判定
  React.useEffect(() => {
    /** 周波数テキストフィールドに1以上の数字が入力された時以外はエラー */
    const error = Number.isNaN(Number(frequency)) || Number(frequency) <= 0;
    setIsError(error);
    if (error) {
      setPlayingFlag(false);
    }
  }, [frequency]);

  // サイン波停止/再生切り替え
  React.useEffect(() => {
    if (oscillator.current) {
      oscillator.current.stop();
      oscillator.current.disconnect();
      oscillator.current = undefined;
    }
    if (!isError && playing) {
      oscillator.current = audioContext.createOscillator();
      oscillator.current.type = "sine";
      oscillator.current.frequency.value = Number(frequency);
      oscillator.current.connect(audioContext.destination);
      oscillator.current.start();
    }
  }, [audioContext, frequency, isError, playing]);

  return {
    playing,
    onClickPlayStopButton,
    frequency,
    onChangeFrequencyTextField,
    isError
  };
};

const App: React.VFC = () => {
  const {
    playing,
    onClickPlayStopButton,
    frequency,
    onChangeFrequencyTextField,
    isError
  } = useStateHook();

  return (
    <>
      <div>
        <MaterialUI.TextField
          error={isError}
          label="Frequency"
          onChange={onChangeFrequencyTextField}
          type="number"
          value={frequency}
        />
      </div>
      <div>
        <MaterialUI.Button
          color={playing ? "error" : "primary"}
          disabled={isError}
          onClick={onClickPlayStopButton}
          variant="contained"
        >
          {playing ? "🔇STOP" : "🔊PLAY"} {frequency}HZ SINE WAVE
        </MaterialUI.Button>
      </div>
    </>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));
```

# 見どころ
ごちゃっとした`useStateHook`の中に`AudioContext`も`OscillatorNode`も全部入っているのに、`Hook`の外側から見えるのは周波数と再生中フラグと周波数テキストボックスの`onChange`イベントとオンオフ切り替えイベントとエラーフラグしか見えないようになっているのが見どころ。

# （私見）僕はこう思ったっス
QiitaでCodePenがプレビューできるってもんだからやってみたけど、CodePenってファイルを分けることできないの知らなかった。
でも簡単なTypeScriptなら書けるからどんどん書いていきたい。