1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【JavaScript】マイク音声とPC画面共有音声を同時録音する方法

Last updated at Posted at 2025-09-29

はじめに

こんにちは!本記事では、MediaStream 収録API と画面キャプチャAPIを使用して、JavaScriptでマイク音声とPC画面共有音声を同時に録音する方法について解説します。

この記事の対象者

  • ブラウザでマイク音声とPC画面音声両方の録音機能を実装したい方

開発環境

  • TypeScript

実装アプローチ

設計方針

  1. マイク音声の取得: getUserMedia() でマイクアクセス
  2. 画面共有音声の取得: getDisplayMedia() で画面音声を取得
  3. 音声合成: 2つの音声ストリームを合成
  4. 録音・保存: 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音声とマイク音声両方の録音が可能になります。
この実装パターンが、同様の機能要件を持つ方の参考になれば幸いです!

参考資料

Web標準仕様

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?