オーディオ波形動画を生成するWebぺージの作り方

本記事はVoicy Advent Calendar 2022の11日目の記事です。

はじめに

こんにちは、株式会社VoicyでWebフロントエンジニアをしているきーくん(@komura_c)です。今回は、業務に関わる内容ではないのですが、以前に趣味で開発したオーディオ波形動画を生成するWebぺージの内容を整理してまとめました。ぜひ読んでみてください!

この記事の内容

主な機能として、画像の読み込み、オーディオファイルの読み込み、波形の描画、キャプチャ、動画として出力をWebフロントエンドで行うWebぺージを作成しました。初めはHTML&CSSと素のJavaScriptで作っていたのですが、Next.jsに移行しました。そのため、TypeScriptとReactを使用したコードが出てきます。 music-waves-visualizer.vercel.app

個人開発したWebページの画像

次の順番で説明していきたいと思います。

オーディオ処理(Web Audio API)

まず、オーディオ処理ではWeb Audio APIを使用しました。 Web Audio APIとは、AudioContextを用いて、音声をルーティングできるように設計されたWeb APIです。 AudioNodeというものを接続することで、複数の音源を1つのコンテキスト内で扱うことができます。

AudioContextとは、AudioNodeという音声処理モジュールで表現されたインターフェースです。AudioNodeの作成と音声処理、デコードの実行の全てを制御する役割を持っています。実際にはこのAudioNode同士をルーティング、繋げていくことで、様々な音声処理を行うことができます。

この辺りはMDNを参考にしました。より詳しい内容については次のリンクを参照してください。 developer.mozilla.org

オーディオルーティング構成

今回使っているAudioNode同士のルーティング構成の図です。

ルーティング構成の図

オーディオ処理の流れ

処理の流れは次のようになっています。

1.初期ロード時に、AnalyserNode、MediaStreamAudioDestinationNodeを作成

  // Audio State
  const audioCtxRef = useRef<AudioContext>(null);
  const streamDestinationRef = useRef<MediaStreamAudioDestinationNode>(null);
  const analyserRef = useRef<AnalyserNode>(null);
  useEffect(() => {
    // AudioContext
    audioCtxRef.current = new AudioContext();

    // AnalyserNode
    const analyserNode = audioCtxRef.current.createAnalyser();
    analyserNode.fftSize = 2048;
    analyserRef.current = analyserNode;

    // MediaStreamAudioDestinationNode(動画出力用)
    const steamDest = audioCtxRef.current.createMediaStreamDestination();
    streamDestinationRef.current = steamDest;
  }, []);

2.ファイル選択時に、ファイルを読み込み、AudioBufferを作成

  // AudioLoadEvent
  const audioLoad = async (event: { target: HTMLInputElement }) => {
    const audioFile = event.target.files[0];
    try {
      const arraybuffer = await audioFile.arrayBuffer();
      decodedAudioBufferRef.current = await audioCtxRef.current.decodeAudioData(
        arraybuffer
      );
      setPlaySoundDisabled(false);
      setRecordMovieDisabled(false);
      openSnackBar("音楽を読み込みました");
    } catch (error) {
      openSnackBar(error);
    }
  };

3.再生時に、AudioBufferSourceNodeを作成、AudioNodeを接続、再生

  const setAudioBufferSourceNode = () => {
    // AudioBufferSourceNode作成
    const audioBufferSourceNode = audioCtxRef.current.createBufferSource();
    audioBufferSourceNode.buffer = decodedAudioBufferRef.current;
    audioBufferSourceNode.loop = false;
    // Node接続
    audioBufferSourceNode.connect(analyserRef.current);
    audioBufferSourceNode.connect(audioCtxRef.current.destination);
    audioBufferSourceNode.connect(streamDestinationRef.current);
    audioBufferSrcRef.current = audioBufferSourceNode;
  };
...
  // PlaySoundEvent
  const onPlaySound = () => {
...
    audioBufferSrcRef.current.start(0);
...
  };

波形描画処理(Canvas API)

次に、波形を描画する処理では、Canvas APIを使用しました。 Canvas APIとは、JavaScriptと、HTMLの<canvas>タグの要素によってグラフィックを描画できるものです。Web上でのアニメーションや、ゲームのグラフィック、データの可視化、写真加工、リアルタイム動画処理などに応用されています。

Canvas自体は描画するだけなため、これをアニメーションにするには、変更がある度にCanvasを更新する処理をする必要があります。 そこでWindow.requestAnimationFrameというメソッドをJavaScriptから呼びます。

Window.requestAnimationFrameとは、指定した関数を引数として渡し、再描画毎にその関数を呼び出し続け、アニメーションを更新するメソッドです。コールバックの回数は、通常毎秒60回(または、ディスプレイのリフレッシュレート=描画フレーム数)とされています。

この辺りもMDNを参考にしました。より詳しい内容については次のリンクを参照してください。 developer.mozilla.org

波形描画処理の流れ

処理の流れは次のようになっています。

1.canvas要素を生成し、window.requestAnimationFrameに更新関数を渡す

2.更新関数にAnalyzerNodeを引数として渡しているため、音声再生のタイミングで描画される

  // Canvas
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const reqIdRef = useRef<number>(null);
  // Canvas用ImageContext
  const [imageCtx, setImageCtx] = useState<HTMLImageElement>(null);
  // Canvas Animation
  useEffect(() => {
    if (!canvasRef.current) {
      return;
    }
    reqIdRef.current = requestAnimationFrame(function () {
      return drawBars(canvasRef.current, imageCtx, mode, analyserRef.current);
    });
    return () => cancelAnimationFrame(reqIdRef.current);
  }, [imageCtx, mode]);

動画キャプチャ処理(Media​Stream Recording API)

次に、動画に出力するためのキャプチャ処理では、Media​Stream Recording APIを使用しました。 Media​Stream Recording APIとは、MediaStreamオブジェクトまたは HTMLMediaElement オブジェクトによって生成されたデータを分析、処理、または保存のためにキャプチャすることができるものです。<audio>, <video>タグである、HTMLMediaElementもメディアソースとして扱うことができます。

この辺りもMDNを参考にしました。より詳しい内容については次のリンクを参照してください。 developer.mozilla.org

動画キャプチャ処理の流れ

処理の流れは次のようになっています。

1.MediaStreamAudioDestinationNodeのデータをMediaStreamに変換したものとCanvas要素をMediaStreamに変換したものを繋げて、新しいMediaStreamを作成

2.作成したMediaStreamをMediaRecorderに変換

3.音声再生のタイミングで録音開始をする(MediaRecorder.start())

4.音声停止のタイミングで録音停止をする(MediaRecorder.stop())

  // RecordMovieEvent
  const onRecordMovie = () => {
    const audioStream = streamDestinationRef.current.stream;
    const canvasStream = canvasRef.current.captureStream();
    const outputStream = new MediaStream();
    [audioStream, canvasStream].forEach((stream) => {
      stream.getTracks().forEach(function (track: MediaStreamTrack) {
        outputStream.addTrack(track);
      });
    });
    //ストリームからMediaRecorderを生成
    const recorder = new MediaRecorder(outputStream, {
      mimeType: "video/webm;codecs=h264",
    });
    const recordedBlobs: Blob[] = [];
    recorder.addEventListener("dataavailable", (e) => {
      recordedBlobs.push(e.data);
    });
...

webmをmp4に変換する処理(ffmpeg.wasm)

動画キャプチャ処理で使った、MediaRecorderは開発した時点では、mp4形式で保存ができなかったので、webm形式で保存しています。 これをなんとかmp4形式に対応するために、ffmpeg.wasmを使用しています。 この処理に関しては、過去に当ブログ記事でまとめているため、そちらをご覧ください。

tech-blog.voicy.jp

おわりに

オーディオ波形動画を生成するWebぺージを開発した内容をまとめてみました。 かなり説明を省いた所はありましたが、参考になれば幸いです。

また、作成したページの全コードはGithubに上げてあるので興味があればご覧ください。 github.com

Webとオーディオ領域というのは奥が深く、以前当ブログでも紹介したWeb Speech APIなど様々なWeb APIが提供されています。 今後さらに進歩していくのが楽しみですし、Web版のVoicyの開発にも何らかの形で活かしていきたいと考えています。

明日は、@yuji0316さんです。

採用情報

Voicyのエンジニアチームに少しでも興味をお持ちになった方は、ぜひこちらもご覧ください。 www.wantedly.com