Reactで、マイクの音声を録音して再生する方法
ブラウザでマイクの音声を録音するには、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、数分を超える長い録音、音声の処理を切ったときに録音した音がどう変わるか、サーバーレンダリングは試していません。

マイクの音声を録音するフック
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つです。
再生とダウンロードができるコンポーネント
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 を渡します。
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 は使えなくなっていました。





