はじめに
こんにちは!本記事では、MediaStream 収録API と画面キャプチャAPIを使用して、JavaScriptでマイク音声とPC画面共有音声を同時に録音する方法について解説します。
この記事の対象者
- ブラウザでマイク音声とPC画面音声両方の録音機能を実装したい方
開発環境
- TypeScript
実装アプローチ
設計方針
-
マイク音声の取得:
getUserMedia()でマイクアクセス -
画面共有音声の取得:
getDisplayMedia()で画面音声を取得 - 音声合成: 2つの音声ストリームを合成
- 録音・保存: MediaRecorderで録音しファイル保存
実装例
// 音声ストリーム管理クラス
class MediaStreams {
micStream: MediaStream
screenStream: MediaStream
combinedStream: MediaStream
constructor(micStream: MediaStream, screenStream: MediaStream, combinedStream: MediaStream) {
this.micStream = micStream
this.screenStream = screenStream
this.combinedStream = combinedStream
}
get recorderStream(): MediaStream {
return this.combinedStream
}
stop(): void {
this.micStream.getTracks().forEach(track => track.stop())
this.screenStream.getTracks().forEach(track => track.stop())
this.combinedStream.getTracks().forEach(track => track.stop())
}
}
// メイン録音クラス
class AudioRecorder {
private audioContext: AudioContext | null = null
private mediaStreams: MediaStreams | null = null
private mediaRecorder: MediaRecorder | null = null
private recordedChunks: Blob[] = []
async startRecording(includeSystemAudio: boolean = true): Promise<void> {
try {
// Step 1: マイク音声取得
const micStream = await navigator.mediaDevices.getUserMedia({ audio: true })
// Step 2: 画面共有音声取得
const screenStream = await this.getScreenAudio()
// Step 3: 音声合成
const combinedStream = await this.combineAudioStreams(micStream, screenStream)
this.mediaStreams = new MediaStreams(micStream, screenStream, combinedStream)
// Step4: 録音開始
this.setupRecorder()
this.mediaRecorder?.start()
} catch (error) {
this.cleanup()
throw error
}
}
private async getScreenAudio(): Promise<MediaStream | undefined> {
const stream = await navigator.mediaDevices.getDisplayMedia({ audio: true })
// 音声トラックが含まれているかチェック
const audioTracks = stream.getAudioTracks()
if (audioTracks.length === 0) {
stream.getTracks().forEach(track => track.stop())
throw new Error('画面音声がありません')
}
// ビデオトラックを停止することでストリームのサイズを抑える
stream.getVideoTracks().forEach(track => track.stop())
return stream
}
/**
* マイクと画面共有音声を結合した音声のStream
*/
private async combineAudioStreams(micStream: MediaStream, screenStream: MediaStream): Promise<MediaStream> {
// 既存のAudioContextをクリーンアップ
if (this.audioContext && this.audioContext.state !== 'closed') {
await this.audioContext.close()
}
this.audioContext = new AudioContext()
const destination = this.audioContext.createMediaStreamDestination()
// 各音声ソースを作成して接続
const micSource = this.audioContext.createMediaStreamSource(micStream)
const screenSource = this.audioContext.createMediaStreamSource(screenStream)
micSource.connect(destination)
screenSource.connect(destination)
return destination.stream
}
private setupRecorder(): void {
if (!this.mediaStreams) return
const stream = this.mediaStreams.recorderStream
const mimeType = this.getSupportedMimeType()
this.mediaRecorder = new MediaRecorder(stream, { mimeType })
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.recordedChunks.push(event.data)
}
}
this.mediaRecorder.onstop = () => {
this.processRecordedData()
}
}
private getSupportedMimeType(): string {
const types = ['audio/webm', 'audio/mp4', 'audio/ogg']
for (const type of types) {
if (MediaRecorder.isTypeSupported(type)) {
return type
}
}
throw new Error('対応している音声フォーマットがありません')
}
private processRecordedData(): void {
if (this.recordedChunks.length === 0) return
const blob = new Blob(this.recordedChunks, {
type: this.mediaRecorder?.mimeType || 'audio/webm'
})
// ファイルとして保存
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `meeting-recording-${new Date().getTime()}.webm`
a.click()
URL.revokeObjectURL(url)
}
stopRecording(): void {
this.mediaRecorder?.stop()
this.cleanup()
}
private cleanup(): void {
this.mediaStreams?.stop()
if (this.audioContext && this.audioContext.state !== 'closed') {
this.audioContext.close()
}
this.recordedChunks = []
this.mediaStreams = null
this.mediaRecorder = null
this.audioContext = null
}
}
// 使用例
const recorder = new AudioRecorder()
// マイク+PC音声で録音開始
document.getElementById('startBtn')?.addEventListener('click', async () => {
try {
await recorder.startRecording(true)
console.log('録音開始')
} catch (error) {
console.error('録音開始エラー:', error)
}
})
// 録音停止
document.getElementById('stopBtn')?.addEventListener('click', () => {
recorder.stopRecording()
console.log('録音停止')
})
ポイント
1. 画面共有音声取得
音声トラック存在チェック
if (audioTracks.length === 0) {
stream.getTracks().forEach(track => track.stop())
throw new Error('音声が共有されていません')
}
getDisplayMedia() は getUserMedia() と異なり、ユーザーが共有対象を選択できます:
- タブ: 音声チェックボックスで選択可能(デフォルトON)
- ウィンドウ: 音声トラックなし
- 画面全体: 音声チェックボックスで選択可能(デフォルトOFF)
音声トラックがない場合、InvalidStateError: Failed to execute 'createMediaStreamSource' on 'AudioContext': MediaStream has no audio track エラーとなるため、事前チェックが重要です。
ビデオトラック停止
stream.getVideoTracks().forEach(track => track.stop())
getDisplayMedia() では音声のみを指定できないため、ストリーム開始時にはビデオトラックが含まれます。
ビデオトラック停止の重要な理由:
- 帯域幅節約: ビデオデータは音声の数十倍のサイズ
- プライバシー保護: 画面の映像データを不要に保持しない
- パフォーマンス: CPU/GPU負荷の削減
2. 音声合成の実装
ストリーム合成
複数のトラックを単純にストリームに追加する方法もありますが、環境によっては最初のトラック以外が録音されない場合があります:
// 環境によっては動作しない実装例
let combinedStream = new MediaStream();
micStream.getAudioTracks().forEach(track => {
combinedStream.addTrack(track);
});
screenStream.getAudioTracks().forEach(track => {
combinedStream.addTrack(track);
});
ストリームに対してソースを接続することで、複数の音声トラックを録音できます。
this.audioContext = new AudioContext()
const destination = this.audioContext.createMediaStreamDestination()
const micSource = this.audioContext.createMediaStreamSource(micStream)
const screenSource = this.audioContext.createMediaStreamSource(screenStream)
micSource.connect(destination)
screenSource.connect(destination)
return destination.stream
注意点
ブラウザ対応
ブラウザにより、利用できるオプションに制限があります。
また、基本的にモバイル対応はしていません。
セキュリティとプライバシー
- ユーザーの明示的な許可が必要
- 録音停止→再開のたびに共有対象を選択してもらう必要あり
おわりに
今回実装した録音機能により、PC音声とマイク音声両方の録音が可能になります。
この実装パターンが、同様の機能要件を持つ方の参考になれば幸いです!