MENU

問い合わせ


    【NuxtとWebRTCで作るビデオチャット 第5回】ミュートとカメラオフ

    前回の第4回:ルーム作成と招待リンク、入室前プレビューで、ルームを作って招待リンクを送り、入室前にカメラを確認できるようになりました。今回は、ビデオ通話に欠かせない「マイクのミュート」と「カメラのオフ」を追加します。

    「ミュートボタンなんて、ボタンを1つ置くだけでしょう?どうしてそこに工数がかかるの?」

    結論から言うと、マイクやカメラを止めること自体は track.enabled = false の1行でできます。工数がかかるのは、その状態を相手の画面にどう見せるかの部分です。相手からは「黒い映像」と「無音」が届くだけなので、何もしないと「相手のカメラが壊れたのか、わざとオフにしているのか」が区別できません。今回はシグナリングで状態を伝え、相手の画面に「ミュート中」「カメラをオフにしています」と表示するところまで作ります。

    目次

    今回作るもの:ミュート・カメラオフのボタンと相手側の表示

    • 通話中の画面に「マイクをミュート」「カメラをオフ」「退出する」の3つのボタンを並べる
    • ミュートすると相手には無音が、カメラをオフにすると黒い映像が届く(接続は切らない)
    • 自分の状態をシグナリングで相手に送り、相手の画面に「ミュート中」のバッジと「相手はカメラをオフにしています」の表示を出す
    • 相手が後から入ってきた場合も、その時点の状態を伝える

    追加・変更するファイル

    ファイル役割
    code/app/composables/useLocalMedia.tsオン/オフの状態(audioEnabled / videoEnabled)と切り替え関数を追加
    code/shared/types/signaling.tsMediaState 型と media-state メッセージを追加
    code/server/utils/signaling.ts / code/server/routes/_ws.tsmedia-state の検証と中継
    code/app/composables/useRemoteMediaState.ts自分の状態の送信と、相手の状態の受信(新規)
    code/app/components/CallControls.vue操作ボタン(新規)
    code/app/components/RemoteVideo.vue / LocalPreview.vueオフ状態の表示を追加
    code/app/pages/room/[id].vue上記の組み込み

    仕組み:enabled で止め、状態はシグナリングで伝える

    マイクやカメラを「止める」方法はいくつかありますが、通話中のミュートには MediaStreamTrack.enabled を使います。

    方法相手に届くもの再接続・再交渉向いている用途
    track.enabled = false無音(音声)/黒いフレーム(映像)不要ミュート・カメラオフ
    track.stop()何も届かなくなる。再開するにはカメラの取得からやり直し取り直したトラックの差し替えが必要退出・ページを離れるとき
    replaceTrack(null)送信を止める不要送信の一時停止(第7回で扱う replaceTrack の応用)

    MDN によると、enabled を false にすると音声はすべてのサンプルが0の無音フレームに、映像は真っ黒なフレームになります。また、カメラの映像であれば、機器の「使用中」ランプが消えるなど、録画・配信していない状態として表示が更新されるとされています。ミュート機能を実装するときは、enabled を使うよう明記されています。

    📰 出典:MDN「MediaStreamTrack: enabled property」

    一方で、相手側のブラウザから見ると「無音」と「黒い映像」が届いているだけです。回線が悪くて映像が止まっているのか、相手が自分でオフにしたのかは、映像だけでは判断できません。そこで、第2回で作ったシグナリングに media-state(マイク・カメラの状態)というメッセージを追加して、状態を文字で伝えます。

    実装1:useLocalMedia にオン/オフを追加する

    code/app/composables/useLocalMedia.ts(追加部分)

      // 第5回: マイク・カメラのオン/オフ(ミュート・カメラオフ)。デバイスを取り直しても引き継ぐ
      const audioEnabled = ref(true)
      const videoEnabled = ref(true)
    
      /** track.enabled = false で、音声は無音・映像は黒いフレームになる(接続はそのまま) */
      function applyEnabled(target: MediaStream | null) {
        target?.getAudioTracks().forEach((track) => { track.enabled = audioEnabled.value })
        target?.getVideoTracks().forEach((track) => { track.enabled = videoEnabled.value })
      }
    
      function setAudioEnabled(enabled: boolean) {
        audioEnabled.value = enabled
        applyEnabled(stream.value)
      }
    
      function setVideoEnabled(enabled: boolean) {
        videoEnabled.value = enabled
        applyEnabled(stream.value)
      }

    状態は ref で持ち、トラックの enabled に反映します。start() でカメラやマイクを取り直したときにも applyEnabled(next) を呼んでいるので、ミュート中にデバイスを切り替えても、いきなり声が相手に届くことはありません。

    RTCPeerConnection に載せているのは同じトラックなので、enabled を変えるだけで相手に届く中身が変わります。offer/answer のやり直し(再交渉)も起きません。

    実装2:状態を伝えるメッセージを追加する

    メッセージの型

    code/shared/types/signaling.ts(追加部分)

    /** 第5回: マイク・カメラのオン/オフ状態(true = オン) */
    export type MediaState = {
      audio: boolean
      video: boolean
    }
    
    // ClientMessage に追加
      | { type: 'media-state'; state: MediaState }
    
    // ServerMessage に追加
      | { type: 'media-state'; from: PeerId; state: MediaState }

    サーバーでの検証と中継

    code/server/utils/signaling.ts(parseClientMessage の追加部分)

    function isMediaState(v: unknown): v is MediaState {
      return isRecord(v) && typeof v.audio === 'boolean' && typeof v.video === 'boolean'
    }
    
        case 'media-state':
          // 第5回: 余計なプロパティは捨て、audio / video だけを中継する
          return isMediaState(data.state)
            ? { type: 'media-state', state: { audio: data.state.audio, video: data.state.video } }
            : null

    code/server/routes/_ws.ts(中継部分)

          case 'description':
          case 'ice-candidate':
          case 'media-state': {
            const roomId = getRoomOf(peer.id)
            if (!roomId) {
              sendTo(peer, { type: 'error', code: 'not-joined', message: '先にルームに参加してください' })
              return
            }
            // 送信者は from としてサーバーが付け直す(ブラウザが名乗った送信者は信用しない)
            broadcast(peer, roomId, { ...msg, from: peer.id })
            return
          }

    第2回で作った「検証してから、同じルームの相手に from を付けて中継する」流れにそのまま乗せています。中継するメッセージが3種類になったので、{ ...msg, from: peer.id } とまとめて書くようにしました。msg は検証済みの型なので、TypeScript がそれぞれ正しい ServerMessage になっているかをチェックしてくれます。

    検証でプロパティを audio と video だけに詰め直しているのは、ブラウザから余計なデータ(たとえば大きな文字列)を付けて送られても、そのまま相手に流さないためです。

    実装3:相手と状態をやりとりする useRemoteMediaState

    code/app/composables/useRemoteMediaState.ts

    export function useRemoteMediaState(
      signaling: SignalingChannel,
      localState: () => MediaState,
      remotePeerId: Ref<PeerId | null>,
    ) {
      /** 相手の状態。まだ届いていなければ null(=不明。表示は「オン」扱いにしておく) */
      const remoteState = ref<MediaState | null>(null)
    
      function sendState() {
        signaling.send({ type: 'media-state', state: localState() })
      }
    
      signaling.onMessage((msg) => {
        switch (msg.type) {
          case 'joined':
            // 先にいた相手に、今の自分の状態を伝える(入室前にミュートしていた場合など)
            if (msg.peers.length > 0) sendState()
            break
          case 'peer-joined':
            // 後から来た相手にも伝える
            sendState()
            break
          case 'media-state':
            if (msg.from === remotePeerId.value) remoteState.value = msg.state
            break
        }
      })
    
      // 相手が変わった・いなくなったら、前の相手の状態は捨てる
      watch(remotePeerId, () => { remoteState.value = null })
    
      // 自分の状態が変わったら、その都度送る(ルームに入っていなければ send は何もしない)
      watch(localState, sendState, { deep: true })
    
      return { remoteState }
    }

    状態を送るタイミングは3つです。

    1. 自分が入室したとき、先に誰かがいれば送る(joined)
    2. 相手が後から入ってきたら送る(peer-joined)
    3. 自分がボタンを押して状態が変わったら送る(watch)

    「変わったときだけ送る」にしてしまうと、ミュートしてから相手が入ってきた場合に、相手は状態を知ることができません。途中から参加した人にも今の状態を伝えるのが、状態同期で漏れやすいポイントです。

    相手の状態が届く前は null にしておき、表示上は「オン」として扱います(何も表示しない)。

    実装4:ボタンと表示

    操作ボタン CallControls.vue

    code/app/components/CallControls.vue(template 部分)

    <template>
      <div class="controls" role="toolbar" aria-label="通話の操作">
        <!-- aria-pressed で「押された状態(=オフ)」を読み上げソフトにも伝える -->
        <button type="button" :aria-pressed="!audioEnabled" :data-off="!audioEnabled" @click="emit('toggle-audio')">
          {{ audioEnabled ? 'マイクをミュート' : 'ミュート解除' }}
        </button>
        <button type="button" :aria-pressed="!videoEnabled" :data-off="!videoEnabled" @click="emit('toggle-video')">
          {{ videoEnabled ? 'カメラをオフ' : 'カメラをオン' }}
        </button>
        <button type="button" class="controls__leave" @click="emit('leave')">
          退出する
        </button>
      </div>
    </template>

    ボタンは状態を持たず、親から受け取った audioEnabled / videoEnabled を表示し、クリックされたことを親に伝えるだけです。オフのときは赤系の色にして、ひと目で分かるようにしています(色だけに頼らず、文言も「ミュート解除」などに変えています)。

    相手の状態を表示する RemoteVideo.vue

    code/app/components/RemoteVideo.vue(追加部分)

        <template v-if="stream && mediaState">
          <!-- カメラオフの間は黒い映像が届くので、その上に理由を表示する -->
          <p v-if="!mediaState.video" class="remote__camera-off">
            相手はカメラをオフにしています
          </p>
          <span v-if="!mediaState.audio" class="remote__badge">ミュート中</span>
        </template>

    props に mediaState を追加し、カメラオフなら映像の上に文字を重ね、ミュートなら左上にバッジを出します。自分の映像(LocalPreview)にも videoOff を追加し、カメラオフ中は「カメラはオフです」と表示します。

    ルームページで組み合わせる

    code/app/pages/room/[id].vue(script の追加部分と template の抜粋)

    <script setup lang="ts">
    const { stream: localStream, stop: stopMedia, audioEnabled, videoEnabled, setAudioEnabled, setVideoEnabled } = media
    const signaling = useSignaling()
    const { remotePeerId, remoteStream, connectionState, close: closePeer } = usePeerConnection(signaling, () => localStream.value)
    const { remoteState } = useRemoteMediaState(
      signaling,
      () => ({ audio: audioEnabled.value, video: videoEnabled.value }),
      remotePeerId,
    )
    </script>
    
    <template>
      <!-- 通話中の部分 -->
      <div class="room__videos">
        <RemoteVideo :stream="remoteStream" :media-state="remoteState" />
        <div class="room__self">
          <LocalPreview :stream="localStream" :video-off="!videoEnabled" />
        </div>
      </div>
    
      <CallControls
        :audio-enabled="audioEnabled"
        :video-enabled="videoEnabled"
        @toggle-audio="setAudioEnabled(!audioEnabled)"
        @toggle-video="setVideoEnabled(!videoEnabled)"
        @leave="leave"
      />
    </template>

    第4回まで通話画面の上にあった「退出する」ボタンは、CallControls の中に移しました。

    動作確認の方法

    1. npm run dev(または npm run build → node .output/server/index.mjs)で起動し、2つのウィンドウで同じルームに入る
    2. 片方で「マイクをミュート」→ もう片方に「ミュート中」のバッジが出て、声が聞こえなくなる
    3. 「カメラをオフ」→ もう片方に「相手はカメラをオフにしています」と表示される。ノートPCなどでは、カメラの使用中ランプの状態も確認する
    4. 元に戻すと表示が消え、映像と音声が戻る
    5. 片方が退出している間にカメラをオフにし、相手が入り直したときにも「カメラをオフにしています」と表示される

    筆者の環境では、Playwright で Chromium を操作し、テスト用のカメラ・マイク(--use-fake-device-for-media-stream)で次の点を確認しました。受信側の映像の明るさは video 要素を canvas に描いて平均を取り、音声の大きさは getStats() の audioLevel(受信した音声のレベル)で見ています。

    操作(送信側)受信側の映像の明るさ(0〜255)受信側の音声レベル受信側の表示
    通常510 より大きいなし
    ミュート47(映像はそのまま)0「ミュート中」
    ミュート+カメラオフ0(真っ黒)0「ミュート中」「カメラをオフにしています」
    両方オンに戻す490 より大きいなし
    相手の入室前にカメラオフ00 より大きい「カメラをオフにしています」

    ボタンの aria-pressed や文言の切り替え、ローカルのトラックの enabled の値も確認しています。実際のカメラの使用中ランプの消灯、実際のマイクでの音声、Safari・Firefox・スマホでの挙動は、この環境では確認していません。npm run build と npm run typecheck は通っています。

    つまずきやすい点とセキュリティ上の注意

    • ミュートは「送信側のブラウザで止める」もの:enabled = false にした音声は無音として送られるので、相手側で解除することはできません。逆に言えば、相手の画面の表示(バッジ)は自己申告です。表示を信じてよいのは「相手のブラウザが正しいコードで動いている」前提の範囲です。
    • 状態の表示と実際の状態がずれないように:表示用の状態(audioEnabled)とトラックの enabled を別々に更新すると、ずれが生じやすくなります。今回は1つの関数(applyEnabled)で必ず両方をそろえています。デバイス切り替えの後も同様です。
    • 途中参加者への状態伝達を忘れない:前述のとおり、変化したときだけ送ると後から入った人に伝わりません。
    • カメラランプが消えるかはブラウザや機器次第:MDN にはランプが消える例が書かれていますが、「オフにしたら必ずランプが消える」とは限りません。利用者に「オフにしたのにランプが点いている」と言われる可能性を考え、完全に止めたいときは track.stop() で解放する設計も選択肢になります(その場合、オンに戻すときに再取得が必要です)。
    • ブラウザ側で音声が拾われていないケースとは別物:OS のマイク設定がミュートになっている、別のマイクが選ばれている、といった問題はこのボタンでは分かりません。入室前プレビュー(第4回)で音声レベルを見せるなど、別の対策が必要です。

    発注者向けメモ:「ミュート機能」を画面単位で確認する

    ミュートやカメラオフは、どのビデオ会議ツールにもある機能なので、「当然ついているもの」と考えがちです。ただし、今回見たように自分の画面・相手の画面・途中参加・デバイス切り替え時など、表示と状態のパターンは意外と多くあります。見積もりや仕様確認の段階で、画面ごとに期待する動きを書き出しておくと、「思っていた動きと違う」を減らせます。

    • ☐ ミュート中・カメラオフ中に、自分の画面と相手の画面にそれぞれ何を表示するか決まっているか
    • ☐ 入室前にミュートしておく(入室時から音声オフ)ことができる必要があるか
    • ☐ 主催者が他の参加者をミュートする機能は必要か(必要なら、権限と「強制的に止める」仕組みの設計が別途必要)
    • ☐ カメラオフのときにランプまで消える必要があるか(業務上のプライバシー要件として)
    • ☐ 通話中にデバイスを切り替えた場合も、ミュート状態が保たれることを確認するか

    開発会社への質問例:

    • 「ミュートしたとき、相手の画面にはどう表示されますか?途中から入った人にも表示されますか?」
    • 「カメラをオフにしたとき、カメラ自体は解放されますか?(ランプは消えますか?)」
    • 「主催者が参加者をミュートする機能は、今回の見積もりに含まれていますか?」

    特に「主催者による強制ミュート」は、今回の自己申告型の仕組みとは別に、権限の管理やサーバー側の仕組みが必要になり、工数が大きく変わるポイントです。必要かどうかを早めに伝えておきましょう。

    まとめと次回予告

    • ミュート・カメラオフは track.enabled = false で実現でき、接続のやり直し(再交渉)は不要。相手には無音と黒い映像が届く
    • 相手が「なぜ映像・音声が来ないか」を分かるように、状態はシグナリング(media-state)で別に伝える
    • 状態は「変わったとき」に加え、「相手が入ってきたとき」にも送る
    • 表示は自己申告であり、主催者による強制ミュートなどは別の設計が必要

    次回(第6回)は「STUN/TURN と本番デプロイ:『社外とつながらない』を解消する」です。第3回から先送りにしてきた、社外ネットワークや携帯回線どうしで接続するための TURN サーバーの設定と、HTTPS 環境へのデプロイを扱います。

    この連載の記事一覧

    この記事は連載「NuxtとWebRTCで作るビデオチャット」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次