Reactで、マイクの音声を録音して再生する方法

​​React​Media Capture and Streams API,MediaRecorder,TypeScript,カスタムフック

ブラウザでマイクの音声を録音するには、navigator.mediaDevices.getUserMedia({ audio: true }) でマイクの使用を許可してもらい、受け取った音声を MediaRecorder で録音します。止めたときにたまったデータを1つの Blob にまとめ、URL.createObjectURL() で作った URL を <audio> の src に渡せば、その場で再生できます。React では、録音の状態と録音した音声の URL をカスタムフックで持つと、コンポーネントの側が簡単になります。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Playwright でヘッドレスの Chromium を動かしました。検証用の環境にはマイクがないため、Chromium の起動オプションで、440Hz の音を流す WAV ファイルを入力にした検証用のマイクに置き換えています。本物のマイクで人の声を録った結果、ブラウザがマイクの許可を尋ねる画面、Firefox、Safari、Google Chrome と Microsoft Edge の製品版、スマートフォン、Windows と macOS、数分を超える長い録音、音声の処理を切ったときに録音した音がどう変わるか、サーバーレンダリングは試していません。

「マイクの録音」の見出しと「録音を始める」ボタンがあるページ。ボタンを押すと、ボタンが「止める(0秒)」と「一時停止」の2つに変わり、秒数が増えていく。「止める」を押すと、ボタンが「録音を始める」に戻り、その下に音声のプレーヤー(0:00 / 0:03)と「録音をダウンロード(57 KB、audio/webm;codecs=opus)」のリンクが出る。プレーヤーの再生ボタンを押すと、右下の再生位置が 0.0秒から増えていく。下の帯に、何回目のクリックかが出ている
録音を始める、止める、再生する、の3回のクリック。変わった表示をピンクで囲む強調、下の帯、再生位置の表示、クリックの輪は、録画用にページへ足したもの

マイクの音声を録音するフック

ts
import { useCallback, useEffect, useRef, useState } from 'react';

// 前から順に、ブラウザが録音できる形式を選ぶ
const MIME_TYPES = ['audio/webm;codecs=opus', 'audio/mp4', 'audio/ogg;codecs=opus'];

export function pickMimeType(): string {
  return MIME_TYPES.find((t) => MediaRecorder.isTypeSupported(t)) ?? '';
}

export const isAudioRecordingSupported =
  typeof navigator !== 'undefined' &&
  typeof navigator.mediaDevices?.getUserMedia === 'function' &&
  typeof MediaRecorder !== 'undefined';

export type RecorderStatus = 'idle' | 'recording' | 'paused';
export type Recording = { url: string; type: string; size: number };

function errorMessage(e: unknown): string {
  if (e instanceof DOMException) {
    if (e.name === 'NotAllowedError') return 'マイクの使用が許可されていません';
    if (e.name === 'NotFoundError') return 'マイクが見つかりません';
  }
  return `録音を始められませんでした(${e instanceof Error ? e.message : String(e)})`;
}

export function useAudioRecorder() {
  const [status, setStatus] = useState<RecorderStatus>('idle');
  const [result, setResult] = useState<Recording | null>(null);
  const [error, setError] = useState<string | null>(null);
  const recorderRef = useRef<MediaRecorder | null>(null);
  const urlRef = useRef<string | null>(null);
  // getUserMedia() を待っている間に、もう一度押されても始めない
  const startingRef = useRef(false);

  const start = useCallback(async () => {
    if (recorderRef.current || startingRef.current) return;
    startingRef.current = true;
    setError(null);

    let stream: MediaStream;
    try {
      stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    } catch (e) {
      setError(errorMessage(e));
      return;
    } finally {
      startingRef.current = false;
    }

    let recorder: MediaRecorder;
    try {
      const mimeType = pickMimeType();
      recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
    } catch (e) {
      stream.getTracks().forEach((t) => t.stop());
      setError(errorMessage(e));
      return;
    }

    const chunks: Blob[] = [];
    recorder.ondataavailable = (e) => {
      if (e.data.size > 0) chunks.push(e.data);
    };
    recorder.onstop = () => {
      // マイクのトラックを止めて、マイクを手放す
      stream.getTracks().forEach((t) => t.stop());
      const blob = new Blob(chunks, { type: recorder.mimeType });
      if (urlRef.current) URL.revokeObjectURL(urlRef.current);
      urlRef.current = URL.createObjectURL(blob);
      recorderRef.current = null;
      setStatus('idle');
      setResult({ url: urlRef.current, type: blob.type, size: blob.size });
    };

    recorder.start();
    recorderRef.current = recorder;
    setStatus('recording');
  }, []);

  const stop = useCallback(() => {
    const recorder = recorderRef.current;
    if (recorder && recorder.state !== 'inactive') recorder.stop();
  }, []);

  const pause = useCallback(() => {
    const recorder = recorderRef.current;
    if (recorder?.state === 'recording') {
      recorder.pause();
      setStatus('paused');
    }
  }, []);

  const resume = useCallback(() => {
    const recorder = recorderRef.current;
    if (recorder?.state === 'paused') {
      recorder.resume();
      setStatus('recording');
    }
  }, []);

  // 画面から取り除かれたら、録音を止めてマイクを手放し、作った URL も手放す
  useEffect(() => () => {
    const recorder = recorderRef.current;
    if (recorder) {
      recorder.onstop = null;
      if (recorder.state !== 'inactive') recorder.stop();
      recorder.stream.getTracks().forEach((t) => t.stop());
    }
    if (urlRef.current) URL.revokeObjectURL(urlRef.current);
  }, []);

  return { supported: isAudioRecordingSupported, status, result, error, start, stop, pause, resume };
}

start() は、まず getUserMedia() に { audio: true } を渡します。利用者がマイクの使用を許可すると、マイクの音声のトラックが入った MediaStream が返ってきます。検証環境では、トラックは1つで、サンプリングレートは 48000Hz、チャンネル数は1(モノラル)でした。

その MediaStream を MediaRecorder に渡して start() を呼ぶと、録音が始まります。録音したデータは dataavailable のイベントで届くので、配列 chunks にためておきます。stop() を呼ぶと、onstop の中で chunks を1つの Blob にまとめ、URL を作って state に入れます。

onstop では、stream.getTracks() のすべてに stop() を呼んでいます。MediaRecorder を止めただけでは、マイクのトラックが止まらなかったためです。検証環境で MediaRecorder の stop() だけを呼ぶと、MediaRecorder の state は inactive になっても、トラックの状態(readyState)は live のままでした。フックで録音したときは、録音中は live、止めたあとは ended になりました。

MediaRecorder は画面に出さないので ref に持ちます。作った URL は、次に録音したときに手放せるよう、ref にも持っておきます。state にするのは、録音の状態(status)、録音した音声(result)、エラーの文(error)の3つです。

再生とダウンロードができるコンポーネント

tsx
import { useEffect, useState } from 'react';
import { useAudioRecorder } from './useAudioRecorder';

function extension(type: string): string {
  if (type.startsWith('audio/mp4')) return 'm4a';
  if (type.startsWith('audio/ogg')) return 'ogg';
  return 'webm';
}

export function AudioRecorder() {
  const { supported, status, result, error, start, stop, pause, resume } = useAudioRecorder();
  const [seconds, setSeconds] = useState(0);

  // 録音を始めたら 0 に戻し、録音している間だけ秒数を増やす(一時停止中は止める)
  useEffect(() => {
    if (status !== 'recording') return;
    const id = setInterval(() => setSeconds((s) => s + 1), 1000);
    return () => clearInterval(id);
  }, [status]);

  if (!supported) {
    return <p>このブラウザでは録音できません。</p>;
  }

  const handleStart = () => {
    setSeconds(0);
    start();
  };

  return (
    <div className="recorder">
      {status === 'idle' ? (
        <button type="button" onClick={handleStart}>録音を始める</button>
      ) : (
        <>
          <button type="button" onClick={stop}>止める({seconds}秒)</button>
          {status === 'recording' ? (
            <button type="button" onClick={pause}>一時停止</button>
          ) : (
            <button type="button" onClick={resume}>再開</button>
          )}
        </>
      )}
      {error && <p className="error" role="alert">{error}</p>}
      {result && status === 'idle' && (
        <div className="result">
          <audio src={result.url} controls />
          <p>
            <a href={result.url} download={`recording.${extension(result.type)}`}>録音をダウンロード</a>
            ({(result.size / 1024).toFixed(0)} KB、{result.type})
          </p>
        </div>
      )}
    </div>
  );
}

再生は、フックが返した URL を <audio> の src に渡すだけです。controls を付けると、ブラウザの再生ボタンと再生位置のバーが出ます。検証環境で約3秒録音し、<audio> の再生ボタンを押すと、約1秒後の再生位置は 0.95秒で、再生が進んでいました。

同じ URL を <a download> にも渡しているので、リンクを押すと recording.webm という名前で保存されます。保存したファイルを ffprobe で調べると、音声は Opus、48000Hz、モノラルで、長さは 3.00秒でした。

録音された音が、検証用のマイクに流した音と同じかも確かめました。録音を Web Audio API の decodeAudioData() で数値の並びにし、いちばん強い周波数を調べると、入力と同じ 440Hz でした。

録音できる形式は isTypeSupported() で選ぶ

録音できる形式はブラウザによって違うため、フックの pickMimeType() は、MediaRecorder.isTypeSupported() で前から順に調べ、最初に使えたものを選びます。検証環境の Chromium 153 で調べた結果は次のとおりです。

指定した MIME タイプ isTypeSupported()
audio/webm;codecs=opus true
audio/webm true
audio/mp4 true
audio/mp4;codecs=opus true
audio/mp4;codecs=mp4a.40.2(AAC) false
audio/ogg;codecs=opus false
audio/wav false

検証環境では、いちばん前の audio/webm;codecs=opus が選ばれます。audio/mp4 を指定して録音すると、MediaRecorder の mimeType は audio/mp4;codecs=opus で、音声の形式は AAC ではなく Opus でした。WAV(audio/wav)は false で、検証環境の Chromium では録音できる形式に入っていませんでした。

MediaRecorder の start() には、dataavailable を出す間隔をミリ秒で渡すこともできます。ただし、WebM で間隔を渡すと、<audio> の duration が Infinity になり、長さが分からなくなりました。

形式と start() の呼び方 dataavailable の回数 <audio> の長さ
WebM、start() 1回 2.4秒
WebM、start(1000) 3回 Infinity
MP4、start() 1回 2.421秒
MP4、start(1000) 3回 2.421秒

録音を止めてから再生するだけなら、間隔を渡す必要はありません。フックも start() を引数なしで呼んでいます。

一時停止している間の音は、録音に入らない

MediaRecorder.pause() を呼ぶと、録音が止まり、state が paused になります。resume() を呼ぶと、同じ録音の続きに足されていきます。

検証環境で、約1秒録音して一時停止し、約2秒待ってから再開して、さらに約1秒録音しました。できた音声の長さは、<audio> で読むと 1.98秒で、一時停止していた約2秒は入っていません。コンポーネントの秒数の表示も、一時停止している間は「止める(1秒)」のまま増えませんでした。

一時停止している間も、マイクのトラックは live のままでした。pause() は録音を止めるだけで、マイクは手放しません。

声の録音向けの処理を切る

検証環境の Chromium で、{ audio: true } で受け取ったマイクのトラックの getSettings() を見ると、エコーキャンセル(echoCancellation)、ノイズ抑制(noiseSuppression)、音量の自動調整(autoGainControl)が、どれも true になっていました。

楽器の音などを、手を加えずに録りたいときは、getUserMedia() にそれぞれ false を渡します。

ts
const stream = await navigator.mediaDevices.getUserMedia({
  audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
});

検証環境でこの指定をすると、getSettings() の3つは、どれも false になりました。指定できる値は、MDN の echoCancellation のページなどにまとまっています。

許可されないとき、マイクがないとき、HTTP のページのとき

MDN の getUserMedia() のページによると、利用者が許可しなかったときは NotAllowedError、使えるマイクがないときは NotFoundError で失敗します。フックの errorMessage() は、この2つを日本語の文に変えています。

検証環境では、マイクの使用を許可しないままボタンを押すと「マイクの使用が許可されていません」と表示されました。検証用のマイクを使わずに Chromium を起動すると、enumerateDevices() の結果は空で、ボタンを押すと「マイクが見つかりません」と表示されました。どちらの場合も、ボタンは「録音を始める」のままでした。

getUserMedia() は、HTTPS や localhost などで開いたページ(安全なコンテキスト)でしか使えません。MDN にも、安全でないページでは navigator.mediaDevices が undefined になると書かれています。検証環境で、localhost ではない名前で HTTP のページを開くと、isSecureContext は false、navigator.mediaDevices は undefined でした。フックの isAudioRecordingSupported が false になり、コンポーネントはボタンの代わりに「このブラウザでは録音できません。」と表示しました。

ボタンを2回押すと、マイクが使われたまま残る

getUserMedia() は Promise を返すので、結果が出るまでの間は recorderRef が空のままです。比べるために、記事のコードから startingRef を除き、recorderRef だけを見るフックで試しました。このフックで「録音を始める」をダブルクリックすると、getUserMedia() が2回呼ばれました。「止める」を押したあとも、片方のトラックは live のまま残りました。

2回の呼び出しは、どちらも MediaRecorder を作って録音を始めますが、recorderRef に残るのは後のほうだけです。先のほうは「止める」を押しても止まらず、マイクのトラックも止まりません。そこで、getUserMedia() を待っている間は startingRef を true にして、2回目の start() を何もせずに返すようにしました。入れたあとは、ダブルクリックしても getUserMedia() は1回だけで、止めたあとのトラックは ended になりました。

コンポーネントを外したときと、録り直したとき

録音中にコンポーネントが画面から取り除かれたとき(アンマウント)は、useEffect のクリーンアップで録音とトラックを止めます。onstop を null にしてから止めるのは、取り除いたあとのコンポーネントの state を書き換えないためです。検証環境で録音中にコンポーネントを外すと、トラックは live から ended になりました。

もう一度録音したときは、前の URL を URL.revokeObjectURL() で手放してから、新しい URL を作ります。検証環境で2回録音したあと、1回目の URL を fetch() で読むと TypeError: Failed to fetch になり、手放した URL は使えなくなっていました。

​Posted by 86