React(Next.js)+WebAudioで簡単なオーディオプレイヤー+スペクトラムアナライザをつくる

趣味で音楽をつくっているのですが、
それ用の特設サイトをNext.jsでつくったのにあわせてオーディオプレイヤー(スペクトラムアナライザ付き)をReact+WebAudioでつくってみました。

**実際の結果**
https://romot.studio

その記録です。

## 事前

- 単純に再生だけなら`<audio>`だけあればWebAudioいらない
- そうでなくてもReact用音声プレイヤーライブラリたくさんあるのでその方がいい
- 今回使わなかったが、スペクトラムアナライザだけでなく
  - プレイリスト
  - 曲やその再生時間にあわせた背景変更
  - 曲の再生秒数にあわせた歌詞など表示
 
などもつけたかったので必要だったという前提 
↓こんな感じで一応実装はしていた

![test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/56184/0e43b516-7886-7e6e-a262-6842466a13bd.gif)

※ イラストは[ゆーはく](https://twitter.com/yuhack)さま



## 要素を置く

プリロードなどが面倒なので、audioタグを利用しています。

`/pages/index.js`

```jsx
import { useState, useEffect, useRef } from 'react';

export default function Home() {
  const audioRef = useRef(null); // ref経由でaudio要素利用
  const timePositionRef = useRef(null); // ref経由でタイムスライダー要素利用
  const spectrumRef = useRef(null); // ref経由でcanvas要素利用(スペクトラムアナライザ用)
  return (
    <>
      {/* 開始/停止ボタン */}
      <button
        type="button"
      >
       停止
　　　　　　　　　　　　</button>
　　　　　　　　　　　{/* タイムスライダー */}
      <input 
        type="range"
        ref={timePositionRef}
      >
      {/* audio */}
      <audio 
       src="/demo.mp3" 
       ref={audioRef} 
      />
      <canvas className="spectrums" ref={spectrumRef} />
    </>
  );
}
```

## 初期化

初期化し、WebAudio経由でも操作できるようにします。

```jsx
export default function Home() {
  ...
  const audioCtxRef = useRef(null); // ref経由でAudioContext利用
  const [source, setSource] = useState(null); // MediaElementSource
  const [analyserNode, setAnalyserNode] = useState(null); // アナライザー

  // 初期化
  useEffect(() => {
    // 初期化時にAudioContextを作成
    audioCtxRef.current = new AudioContext();
    // createMediaElementSource
    const elementSource = audioCtxRef.current.createMediaElementSource(audioRef.current);
    // スペクトラムアナライザー用node
    const analyser = audioCtxRef.current.createAnalyser();
    // nodeを接続
    elementSource.connect(analyser).connect(audioCtxRef.current.destination);
    setSource(elementSource);
    setAnalyserNode(analyser);
  },[]);

  return (...;  
```

## プレイヤー機能をつける

再生・停止、スライダーで位置変更など

```jsx
export default function Home() {
  ...
  const [playState, setPlayState] = useState('stop'); // 再生状態
  const [duration, setDuration] = useState(0); // 曲の総時間
  const [timePosition, setTimePosition] = useState(0); // タイムスライダーの位置

  // 再生/停止ボタンの操作
  const handleTogglePlay = () => {
    // 初期状態から再生開始
    if (audioCtxRef.current.state === 'suspended'){
      audioCtxRef.current.resume();
      setPlayState('play');
    }

    // 再開
    if (playState === 'stop') {
      audioRef.current.play();
      setPlayState('play');
    // 停止
    if (playState === 'play'){
      audioRef.current.pause();
      setPlayState('stop');
    }
  };

  // スライダーを操作で再生位置変更
  const handleTimeUpdate = () => {
    setTimePosition(audioRef.current.currentTime);
  };

  // スライダーを操作で再生位置変更
  const handleChangeTimePosition = (e) => {
    const position = parseInt(e.target.value);
    setTimePosition(position);
    audioRef.current.currentTime = position;
  };

  // 最後まで再生したら最初に戻り、停止
  const handleEnded = () => {
    setTimePosition(0);
    setPlayState('stop');
  };

  // 音声メタデータ取得時点で総合計時間を設定
  const handleLoadedMetadata = () => {
    const duration = audioRef.current.duration;
    setDuration(duration);
  };

  return (
    <>
      {/* 開始/停止ボタン */}
      <button
        ...
        onClick={handleTogglePlay}
      >
       {playState === 'stop' && '開始'}
       {playState === 'play' && '停止'}
　　　　　　　　　　　　</button>
　　　　　　　　　　　{/* タイムスライダー */}
      <input 
        ...
        min={0}
        max={duration}
        value={timePosition}
        onInput={handleChangeTimePosition}
      >
      {/* audio */}
      <audio 
       ...
       onTimeUpdate={handleTimeUpdate}
       onLoadedMetadata={handleLoadedMetadata}
       onEnded={handleEnded}
      />
    </>
  );  
```

## スペクトラムアナライザ

どこかで見たものをほぼそのまま使いましたが、ソースが見つかりませんでした(ごめんなさい)

上の画像だと背景画像がアナライザ的に色が変わっていますが、同じ形でCSSのfilterでざっくりやっています。

```jsx
export default function Home() {
  ...
  useEffect(() => {
    if (source && analyserNode && playState === 'play') {
      const canvas = spectrumRef.current;
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
      const canvasCtx = canvas.getContext('2d');
      analyserNode.fftSize = 16384; // FFTサイズ
      const canvasWidth = canvas.width;
      const canvasHeight = canvas.height;
      const bufferLength = analyserNode.frequencyBinCount;
      const dataArray = new Uint8Array(bufferLength);
      const barWidth = 1; // バーの横幅

      let barHeight;
      let x = 0;

      function renderFrame() {
        requestAnimationFrame(renderFrame);
  
        x = 0;
  
        analyserNode.getByteFrequencyData(dataArray);
        canvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
        let bars = 128; // バーの数
  
        for (let i = 0; i < bars; i++) {
          barHeight = (dataArray[i]);
  
          canvasCtx.fillStyle = `rgba(255,255,255,0.8)`;
          canvasCtx.fillRect(x, (canvasHeight - barHeight), barWidth, barHeight);  
          x += barWidth + (canvasWidth / 128);
        }
      }
      renderFrame();
    }
  },[playState]);  // 再生状態からhook


  ...

  return (...
```

## 全体

```jsx
import { useState, useEffect, useRef } from 'react';

export default function Home() {
  const [playState, setPlayState] = useState('stop');
  const [duration, setDuration] = useState(0);
  const [timePosition, setTimePosition] = useState(0);
  const [source, setSource] = useState(null);
  const [analyserNode, setAnalyserNode] = useState(null);
  const audioRef = useRef(null);
  const audioCtxRef = useRef(null);
  const timePositionRef = useRef(null);
  const spectrumRef = useRef(null);

  useEffect(() => {
    audioCtxRef.current = new AudioContext();
    const elementSource = audioCtxRef.current.createMediaElementSource(audioRef.current);
    const analyser = audioCtxRef.current.createAnalyser();
    elementSource.connect(analyser).connect(audioCtxRef.current.destination);
    setSource(elementSource);
    setAnalyserNode(analyser);
  },[]);

  useEffect(() => {
    if (source && analyserNode && playState === 'play') {
      const canvas = spectrumRef.current;
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
      const canvasCtx = canvas.getContext('2d');
      analyserNode.fftSize = 16384;
      const canvasWidth = canvas.width;
      const canvasHeight = canvas.height;
      const bufferLength = analyserNode.frequencyBinCount;
      const dataArray = new Uint8Array(bufferLength);
      const barWidth = 1;

      let barHeight;
      let x = 0;

      function renderFrame() {
        requestAnimationFrame(renderFrame);
  
        x = 0;
  
        analyserNode.getByteFrequencyData(dataArray);
        canvasCtx.clearRect(0 ,0, canvasWidth, canvasHeight);
        let bars = 128;
  
        for (let i = 0; i < bars; i++) {
          barHeight = (dataArray[i]);
  
          canvasCtx.fillStyle = `rgba(255,255,255,0.8)`;
          canvasCtx.fillRect(x, (canvasHeight - barHeight), barWidth, barHeight);  
          x += barWidth + (canvasWidth / 128);
        }
      }
      renderFrame();
    }
  },[playState]);

  const handleTogglePlay = () => {
    if (audioCtxRef.current.state === 'suspended'){
      audioCtxRef.current.resume();
      setPlayState('play');
    }

    if (playState === 'stop') {
      audioRef.current.play();
      setPlayState('play');
    } 
　　　　　　　if (playState === 'play') {
      audioRef.current.pause();
      setPlayState('stop');
    }
  };

  const handleTimeUpdate = () => {
    setTimePosition(audioRef.current.currentTime);
  };

  const handleEnded = () => {
    setTimePosition(0);
    setPlayState('stop');
  };

  const handleLoadedMetadata = () => {
    const duration = audioRef.current.duration;
    setDuration(duration);
  };

  const handleChangeTimePosition = (e) => {
    const position = parseInt(e.target.value);
    setTimePosition(position);
    audioRef.current.currentTime = position;
  };

  return (
    <>
      <button
        type="button"
        onClick={handleTogglePlay}
      >
       {playState === 'stop' && '開始'}
       {playState === 'play' && '停止'}
　　　　　　　　　　　　</button>
      <input 
        type="range"
        min={0}
        max={duration}
        value={timePosition}
        onInput={handleChangeTimePosition}
      >
      <audio 
       src="/demo.mp3"
       ref={audioRef} 
       onTimeUpdate={handleTimeUpdate}
       onLoadedMetadata={handleLoadedMetadata}
       onEnded={handleEnded}
      />
      <canvas className="spectrums" ref={spectrumRef} />
    </>
  )
}

```

なにかの参考になれば幸いです。
間違いなどありましたらおしらせください。