プロトコル

ボイスチャットの実装方法|WebRTC自作とVivox・Photon Voice等SDKの選び方【2026年10月】

ボイスチャットの実装方法|WebRTC自作とVivox・Photon Voice等SDKの選び方【2026年10月】

ボイスチャットを実装する方法は、WebRTC で自作するか、ボイスチャット SDK を組み込むかの2通りです。ブラウザで2人が話すだけなら、標準 API と小さな中継サーバで数百行に収まります。一方、Unity のゲームに3D音声を載せたい、iOS・Android・Web を横断したい、参加人数が読めないといった条件が1つでも入ると、SDK を使ったほうが早く確実です。この記事では、WebRTC による1対1通話の TypeScript コードから始め、人数とネットワーク条件で構成がどう変わるか、Vivox・Photon Voice 2・Agora・SkyWay・LiveKit をどの課金単位で比べるべきかを、2026年10月時点の公式情報で整理します。

まとめ:ボイスチャット実装の判断早見表

条件 取るべき構成 主な理由
ブラウザで2人固定 WebRTCのP2Pを自作 シグナリングとSTUN・TURNで足りる
3〜6人 P2P(mesh)+SFU移行を前提に設計 接続数がN(N-1)/2で増える
7人以上・人数が読めない SFU(LiveKit・SkyWay等) 端末の上りを1本に固定できる
Unityのゲーム Vivox か Photon Voice 2 3D音声とゲームの通信に統合済み
モバイルとWebの商用アプリ Agora等の分課金SDK 音声1,000分0.99ドルから
社内網・モバイル回線を含む TURNサーバを必ず用意 会議の22%がTURN経由(実測)
AIと音声で対話 Realtime API系のサーバ接続 クラウド推論先との音声接続が必要

迷ったら「誰と誰が話すか(人同士かAIか)」「何人か」「どの端末か」の順に決めると、自作で済むかSDKが要るかが自然に分かれます。

ブラウザボイスチャットの仕組み:WebRTCが標準で持つ機能

ブラウザボイスチャットとは、アプリのインストールなしにブラウザ同士で音声通話する仕組みで、中身は WebRTC という標準規格です。マイク取得・音声の符号化・経路探索・暗号化までがブラウザに入っているため、開発者が書くのは「相手を見つけて接続情報を渡す」部分だけになります。WebRTC 全体の構成要素はWebRTCとは?P2Pの仕組み・ICE/STUN/TURNからSFU選定・実装・運用判断まで実装者向けに解説で扱っており、ここでは音声に関わる2点に絞ります。

1点目はマイクの入口です。MDN の getUserMedia() の解説にあるとおり、この API はセキュアコンテキストでしか動きません。HTTPS や localhost のようなセキュアコンテキストでないページでは、navigator.mediaDevices 自体が undefined になります。社内の検証サーバを通常のHTTPで公開している場合も、この制約によってマイクを取得できません。

2点目はコーデックです。RFC 7874 の第3章は、WebRTC エンドポイントに Opus と G.711(PCMA・PCMU)の実装を義務づけ(MUST)、8kHz を超えるレートを扱えるなら Opus を先にオファーするよう推奨(RECOMMENDED)しています。ブラウザ同士なら実質 Opus が選ばれるので、コーデックを自分で指定する必要はほとんどありません。G.711は電話網との接続で使われるほか、端末同士でもSDPの提示内容や優先順位に応じて選択されます。

WebRTCで1対1ボイスチャットを実装する手順(TypeScript)

以下の例1〜例4は接続処理の部品です。実行には、依存パッケージとビルド設定、WebSocketの生成、音声要素、createVoicePeer()とcall()を呼ぶ画面側の処理、実在するTURNサーバの設定が別途必要です。発信は両者の入室後に片側だけから行い、相手音声の再生には例5を接続してください。例1〜例5は TypeScript 7.0.2 の strict 設定で型検査し、例4のサーバは Node.js v26.5.0 と ws 8.22.0 で動作を確かめています。

マイク取得と音声処理の設定

// 例1: マイク取得(エコーキャンセル等の音声処理を明示的に有効化)
export async function openMicrophone(): Promise<MediaStream> {
  return navigator.mediaDevices.getUserMedia({
    audio: {
      echoCancellation: true,
      noiseSuppression: true,
      autoGainControl: true,
      channelCount: 1,
    },
    video: false,
  });
}

指定した4つのプロパティは、TypeScript の lib.dom.d.ts の MediaTrackConstraintSet に定義されています。同じ辞書の sampleRate や sampleSize を固定すると、端末が対応していない場合に OverconstrainedError で取得自体が失敗します。通話用途では指定しないほうが安全です。channelCount を1にしているのは、通話音声にステレオの利点がほぼ無く、帯域とエンコード負荷を減らせるためです。

offer・answer交換による接続確立

接続は RTCPeerConnection が担います。発信側が createOffer() で SDP を作り、着信側が createAnswer() で応答し、並行して ICE candidate を交換します。交換するメッセージを判別可能なユニオン型にすると、typeに応じてプロパティの型を絞り込めます。分岐漏れまで検出するには、never型による網羅性チェックなどを別途実装します。

// 例2: シグナリングで交換するメッセージの型(クライアント・サーバ共用)
export type SignalMessage =
  | { type: "join"; room: string }
  | { type: "offer"; room: string; sdp: RTCSessionDescriptionInit }
  | { type: "answer"; room: string; sdp: RTCSessionDescriptionInit }
  | { type: "candidate"; room: string; candidate: RTCIceCandidateInit }
  | { type: "bye"; room: string };
// 例3: 1対1ボイスチャットの接続処理(送信・受信の全経路)
import { openMicrophone } from "./mic.js";
import type { SignalMessage } from "./signal.js";

const ICE_SERVERS: RTCIceServer[] = [
  { urls: "stun:stun.l.google.com:19302" },
  { urls: "turn:turn.example.com:3478", username: "user", credential: "pass" },
];

const send = (ws: WebSocket, msg: SignalMessage): void =>
  ws.send(JSON.stringify(msg));

export function createVoicePeer(
  ws: WebSocket,
  room: string,
  remoteAudio: HTMLAudioElement,
): RTCPeerConnection {
  const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });

  const join = () => send(ws, { type: "join", room });
  if (ws.readyState === WebSocket.OPEN) {
    join();
  } else {
    ws.addEventListener("open", join, { once: true });
  }

  ws.addEventListener("message", (ev: MessageEvent<string>) => {
    handleSignal(pc, ws, room, JSON.parse(ev.data) as SignalMessage);
  });

  pc.onicecandidate = (ev) => {
    if (ev.candidate) {
      send(ws, { type: "candidate", room, candidate: ev.candidate.toJSON() });
    }
  };

  pc.ontrack = (ev) => {
    remoteAudio.srcObject = ev.streams[0] ?? null;
  };

  return pc;
}

function handleSignal(
  pc: RTCPeerConnection,
  ws: WebSocket,
  room: string,
  msg: SignalMessage,
): void {
  const fail = (err: unknown): void => console.error("signal", msg.type, err);
  if (msg.type === "offer") {
    answer(pc, ws, room, msg.sdp).catch(fail);
  } else if (msg.type === "answer") {
    pc.setRemoteDescription(msg.sdp).catch(fail);
  } else if (msg.type === "candidate") {
    pc.addIceCandidate(msg.candidate).catch(fail);
  } else if (msg.type === "bye") {
    endCall(pc);
  }
}

// close() だけではマイクが止まらないので、送信中のトラックを先に stop() する
export function endCall(pc: RTCPeerConnection): void {
  for (const sender of pc.getSenders()) {
    sender.track?.stop();
  }
  pc.close();
}

export function hangUp(pc: RTCPeerConnection, ws: WebSocket, room: string): void {
  send(ws, { type: "bye", room });
  endCall(pc);
}

export async function call(
  pc: RTCPeerConnection,
  ws: WebSocket,
  room: string,
): Promise<void> {
  const stream = await openMicrophone();
  for (const track of stream.getAudioTracks()) {
    pc.addTrack(track, stream);
  }
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  send(ws, { type: "offer", room, sdp: pc.localDescription! });
}

export async function answer(
  pc: RTCPeerConnection,
  ws: WebSocket,
  room: string,
  offer: RTCSessionDescriptionInit,
): Promise<void> {
  await pc.setRemoteDescription(offer);
  const stream = await openMicrophone();
  for (const track of stream.getAudioTracks()) {
    pc.addTrack(track, stream);
  }
  const ans = await pc.createAnswer();
  await pc.setLocalDescription(ans);
  send(ws, { type: "answer", room, sdp: pc.localDescription! });
}

bye を受けたときと自分から切るとき(hangUp)は、送信中のトラックを stop() してから close() します。close() だけではマイクが使用中のまま残り、ブラウザの録音中表示も消えません。

着信側で setRemoteDescription() をマイク取得より先に呼ぶ順序が、接続失敗を防ぐ肝です。逆にすると、ユーザーがマイク許可ダイアログを操作している数秒の間に相手の ICE candidate が届き、リモート SDP 未設定のまま addIceCandidate() が失敗します。

stun.l.google.com:19302 は動作確認用の公開サーバで、SLA の保証はありません。本番では自前の STUN・TURN か SaaS 提供のものを使ってください。TURN の資格情報を例のように静的な文字列で埋め込むのも検証時までです。TURN は中継量に応じて費用がかかるため、資格情報が漏れると第三者の中継に使われます。本番ではサーバ側で有効期限付きの資格情報を発行して渡します。

シグナリングサーバの最小構成

シグナリングサーバの仕事は、同じ部屋にいる相手へ受け取ったメッセージをそのまま渡すことだけで、SDP の中身を解釈する必要はありません。WebSocket の接続確立やハートビートの扱いはWebSocketとは?仕組み・ハンドシェイクからNode.js実装・運用の判断まで実装者向けに解説で詳しく説明しています。

// 例4: 最小シグナリングサーバ(join した部屋の他クライアントへ中継するだけ)
import { WebSocketServer, WebSocket } from "ws";
import type { SignalMessage } from "./signal.js";

const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map<string, Set<WebSocket>>();

// 不正なJSONや形の違うメッセージは null にして捨てる
function parse(raw: Buffer): SignalMessage | null {
  try {
    const msg: unknown = JSON.parse(raw.toString());
    if (typeof msg !== "object" || msg === null) return null;
    const { type, room } = msg as { type?: unknown; room?: unknown };
    if (typeof type !== "string" || typeof room !== "string") return null;
    return msg as SignalMessage;
  } catch {
    return null;
  }
}

function leave(socket: WebSocket, room: string): void {
  const set = rooms.get(room);
  if (!set) return;
  set.delete(socket);
  if (set.size === 0) rooms.delete(room);
}

wss.on("connection", (socket: WebSocket) => {
  let joined: string | null = null;

  socket.on("message", (raw: Buffer) => {
    const msg = parse(raw);
    if (msg === null) return;

    if (msg.type === "join") {
      // 接続中の再入室は受け付けない(部屋を変えるときは接続し直す)
      if (joined !== null) return;
      const set = rooms.get(msg.room) ?? new Set<WebSocket>();
      // 1対1用なので定員は2人
      if (set.size >= 2) {
        socket.close(4001, "room full");
        return;
      }
      set.add(socket);
      rooms.set(msg.room, set);
      joined = msg.room;
      return;
    }

    // 未入室、または自分がいない部屋あてのメッセージは破棄する
    if (joined === null || msg.room !== joined) return;

    for (const peer of rooms.get(joined) ?? []) {
      if (peer !== socket && peer.readyState === WebSocket.OPEN) {
        peer.send(raw.toString());
      }
    }

    if (msg.type === "bye") {
      leave(socket, joined);
      joined = null;
    }
  });

  socket.on("close", () => {
    if (joined !== null) leave(socket, joined);
  });
});

入室を join メッセージの受信に限定している点が要です。「最初に届いたメッセージの room で入室させる」実装にすると、offer を待つだけの着信側は一度も送信しないため部屋に登録されず、1対1通話が成立しません。中継先も、送信者が申告した msg.room ではなく入室済みの joined で決めています。他の部屋へメッセージを流し込まれないためです。1対1用なので部屋の定員は2人とし、接続中の再入室と、JSON として読めないメッセージは捨てます。

このコードは ES モジュールなので、package.json に "type": "module" を明示することを推奨します。ただし、Node.js v26.5.0ではtype未指定の.jsも構文検出によりESモジュールとして読み込まれるため、未指定だけで必ずSyntaxErrorになるわけではありません。4つのクライアントで試すと、同じ部屋に2人が入った後の3人目はコード4001で切断され、別室あてと壊れた JSON は捨てられ、bye を送った側には以後のメッセージが届かないことを確認できます。

3人目で変わる構成:meshの接続数とSFUへの切替基準

ここまでの実装は2人専用です。3人目を入れると、各端末が他の全員と個別に RTCPeerConnection を張る P2P フルメッシュ(mesh)になります。

参加人数 meshの総接続数 1端末が張る接続数 SFUの場合の上り
2人 1 1 1本
3人 3 2 1本
4人 6 3 1本
6人 15 5 1本
10人 45 9 1本

総接続数は N(N-1)/2 で増え、10人で45本です。音声だけなら Opus の帯域は小さいので、先に回線が破綻することはあまりありません。効いてくるのは、端末ごとに人数分のエンコードとジッタバッファを抱える CPU 負荷と、入退室のたびに全員が再ネゴシエーションする制御の複雑さです。

SFU(Selective Forwarding Unit)を挟むと、各端末はサーバへ上り1本を送るだけになり、人数が増えても送信側の負荷は変わりません。音声をサーバで合成するMCU方式は、サーバ側の処理負荷と引き換えに端末の受信ストリーム数を減らせます。SFUとの選択では、サーバ費用だけでなく端末の受信帯域・復号負荷・個別音声の扱いを比較します。

判断は単純です。参加者が固定2人なら mesh で自作、上限を6人程度に絞れるなら mesh のままでも運用できます。人数上限を決められない、録音が要る、途中参加を許すといった要件が1つでもあれば、最初から SFU 前提で設計してください。mesh から SFU への移行は接続管理をほぼ書き直すことになり、後から切り替えるほど手戻りが大きくなります。

TURNを省くと繋がらない環境と、その費用

STUN サーバだけで組んだ実装は、開発者の手元では問題なく動き、本番で落ちます。企業のファイアウォールや一部のモバイル回線では端末同士の直接接続が成立せず、中継サーバ(TURN)を経由しないと音声が流れません。

規模感の参考になるのが、webrtcHacks に掲載された callstats.io の利用統計です。2015年1月から2016年2月のデータをまとめたこの記事では、会議の22%が何らかの TURN リレーを必要とし、約9%は TCP を要したと報告されています。接続失敗の85%は NAT やファイアウォールを越えられないことが原因で、TURN を置けば大半は解消するとも書かれています。10年近く前の数字ですが、企業ネットワークの制限が緩む方向には動いていないので、TURN は「あると安心」ではなく必須構成として見積もりに入れてください。

費用の目安として、SkyWay の Enterprise プランでは TURN 通信料が1GB あたり44円(税込)です。coturn を自前で立てる場合は、中継トラフィックがそのままクラウドの外向き転送料に乗ります。映像を加えると転送量は増えますが、音声だけでも参加者数・通話時間・TURN経由率が大きければ費用が増えるため、月間転送量で見積もります。

Unity・モバイルアプリにボイスチャットを組み込むSDK

ゲームやネイティブアプリでは、WebRTC を直接扱うより、ボイスチャット SDK を組み込むのが一般的です。SDK はマイク権限、エコー除去、再接続、ルーム管理をまとめて引き受け、ゲーム向けのものは話者の位置で音量と方向が変わる3D音声も持っています。ボイストークのような1対1の音声通話も、この SDK の「2人だけのチャンネル」として作れます。

Unityのゲーム:VivoxとPhoton Voice 2の選び方

Vivox は Unity Gaming Services の音声・テキストチャットで、Unity 向けパッケージは com.unity.services.vivox です。2026年10月12日時点のリリースノートの最新は 16.12.1 で、16.12.0 で Android 上の Vivox が使えなくなっていた不具合を直した版です。16.12.0 で止まっているプロジェクトは更新を先に済ませてください。料金はUnity Gaming Services の料金表でピーク同時接続数(PCU)5,000 までが無料、5,001〜50,000 PCU は 5,000 PCU ごとに2,000ドルです。3D空間で話者の位置を反映する positional channel が標準で用意されています。

Photon Voice 2 は、PUN 2 や Fusion で通信を組んでいるゲームに音声を足す SDK です。Photon の SDK 配布一覧では Voice SDK の最新が 2.64 です。Photon Voice の料金表では、無料の20 CCU(同時接続)は開発専用で、公開するには100 CCU を12か月95ドル(一括)、500 CCU を月95ドル、1,000 CCU を月185ドルといったプランを選びます。Fusion 2.0 と 2.1 で Voice のパッケージに互換性が無い点はPhoton Fusionとは?Fusion 2.1の3モードの違い・料金・PUN2からの移行で触れています。

選び方ははっきりしています。すでに Photon で同期しているなら Photon Voice 2 を選び、ゲーム側の入退室に音声側の接続を連動させます。通信基盤がまだ決まっていない、あるいは Unity のサービス群(認証・ロビー)でそろえるなら Vivox が有利で、5,000 PCU まで無料なので小規模タイトルは費用ゼロで公開できます。Photon の無料枠は開発専用なので、公開と同時に有料プランが要る点が分かれ目です。Microsoft の PlayFab にも Party という音声・テキストチャット機能があり、PlayFab でバックエンドを組むなら候補に入ります(PlayFabとは?料金プラン・無料枠・Unity SDKの導入手順【2026年9月時点】)。

検索上位に手順書が残る国産のモノビットエンジン(MUN)は、製品ページで2026年1月19日に 2.9.1 が出ていますが、その前の更新は2022年6月29日です。音声の VR Voice Chat は最終リリースが2020年5月8日の 2.7.0 のままです。新規に採用するなら、使う Unity の版で動くことと、サポート窓口の有無を先に確かめてください。

モバイルとWebの商用アプリ:Agoraと画面部品付きSDK

iOS・Android・Web・Flutter をまたぐ通話アプリなら、Agora のような分課金の SDK が早道です。Agora Voice Calling の料金は音声のみで参加者1,000分あたり0.99ドルで、Free パッケージには毎月10,000分が含まれます。分は参加者ごとに数えるので、4人が毎日1時間話すと 4×60×30=7,200分で Free の範囲に収まります。20人が毎日2時間なら72,000分で、標準単価なら約71ドルです。Free パッケージは上限を超えるとサービスが停止される設定なので、公開後は従量の有料パッケージに切り替えておきます。

Zenn などで紹介されている Tencent の TUILiveKit のように、ボタンや参加者一覧まで画面部品として提供される SDK もあります。数十分で音声ルームを組める手軽さの一方、通話基盤は Tencent のクラウドに依存します。TUILiveKit の画面部品は MIT ライセンスでソースが公開されているので、画面は書き換えられても基盤は差し替えられない、と分けて評価してください。試作や社内向けには向きますが、独自の UI で差別化するアプリでは、部品なしの SDK を選んだほうが後で困りません。動画とライブ配信まで含めた SaaS の比較は100ms.liveとは?WebRTC・HLSのライブ動画SDKの料金と上限、導入手順【2026年9月】にまとめています。

課金単位で比べるボイスチャットSDKの費用

SDK の料金表は単位がそろっていないため、単価を横に並べても比べられません。まず課金単位を確認します。

選択肢 課金単位 無料枠 有料の入口
WebRTC自作 サーバ費+TURN転送量 なし 自前のインフラ費
SkyWay 基本料+接続回数+GB あり(商用不可) 110,000円/月(税込)
LiveKit Cloud 基本料+参加者分数+下り転送量 5,000分/月 50ドル/月(150,000分)
Agora 参加者の分 10,000分/月 0.99ドル/1,000分
Vivox ピーク同時接続(PCU) 5,000 PCU 2,000ドル/5,000 PCU
Photon Voice 2 同時接続(CCU) 20 CCU(開発専用) 95ドル(100 CCU・12か月)

分課金と同時接続課金は、使われ方で有利不利が逆になります。短い通話がまばらに発生するアプリは、話した分だけ払う分課金が安く済みます。長時間接続するゲームでは同時接続課金が有利になることがあります。ただし、Photon Voiceのように通信量枠と超過料金があるサービスでは、通話時間の増加に伴って費用が増える場合もあります。自社の利用ログから「1日の延べ通話分」と「ピーク時の同時接続数」の両方を出し、2つの方式で試算してから選んでください。

自前実装とSaaSの分岐点:費用と撤退リスク

ここまでを踏まえ、本記事では自作を小さく始めやすい条件を「2人固定・TURNを運用できる・外部サービスのSLAが不要」とします。多人数やSLAが必要な構成でも自作はできますが、接続管理や可用性設計の工数が増えます。この条件から外れる場合は、SaaS かオープンソースの SFU を検討します。

SkyWayとLiveKitの費用構造

国産の SkyWay は円建ての公開単価があり、見積もりを社内で通しやすいのが利点です。SkyWay の料金ページの記載(税込・2026年10月12日確認)は次のとおりです。

項目 SkyWay Free SkyWay Enterprise
基本利用料 0円 110,000円/月
接続料 50万回/月まで0円 11円/1,000回
TURN通信料 500GB/月まで0円 44円/GB
SFU通信料 500GB/月まで0円 44円/GB
SFUリソース確保料(音声) 0円 0.00033円/分
ノイズキャンセリング 10,000分/月まで0円 0.055円/分
SLA なし 99.95%
商用利用 不可 可

従量部分より効くのは基本利用料です。Enterpriseの基本利用料は税込月110,000円を上限に日割り計算され、これに接続回数や通信量などの従量料金が加わります。利用者が少ないうちは、分課金の SDK のほうが総額は小さくなります。

オープンソースなら LiveKit が第一候補です。メディアサーバは Apache-2.0 ライセンスで、GitHub の最新リリースは v1.13.9(2026年10月7日)です。自前のサーバで動かせば基本料金はかかりませんが、SFU のスケールと TURN の運用は自分で持ちます。マネージドの LiveKit Cloud は Build が月0ドル(WebRTC 5,000分)、Ship が月50ドル(150,000分、超過は1分0.0005ドル)、Scale が月500ドル(150万分)です。セルフホストの手順と料金の詳細はLiveKitとは?LiveKit Cloudの料金とセルフホストの違い・始め方【2026年10月】で扱っています。

サービス終了リスクの読み方

通話基盤は一度組み込むと差し替えコストが高く、提供元の継続性が技術要件と同じ重さを持ちます。国内で直近に起きたのが旧 SkyWay の終了です。NTTコミュニケーションズの告知のとおり、旧 SkyWay は Community Edition・Enterprise Edition とも2026年4月30日でサービスを終えました。旧 SDK で作られたアプリは、現行 SkyWay への移行か他社への乗り換えを迫られました。

逆の例が Twilio です。Twilio は2023年12月に Programmable Video の提供終了を告知し、終了日を2026年12月5日へ延期したのち、2024年10月21日の changelog で終了の決定そのものを撤回しました。撤回されたとはいえ、告知が出た時点で利用企業には移行検討のコストが現実に発生しています。会議アプリが終わって SDK だけが残った例はAmazon Chimeの終了後に残るChime SDKで会議機能を組み込む手順と料金、通話アプリそのものが終了した顛末はPOPOPOとは?カメラ不要の通話アプリの仕組みと、サービス終了・技術オープンソース化まで徹底解説で追っています。

実装段階でできる備えは1つです。SDK 固有の API を業務ロジックへ直接ばらまかず、接続・切断・ミュート・音量といった操作を自前のインターフェースで包んでおきます。接続操作を分離しておくと、その部分の書き換えを局所化できます。ただし、認証・権限・ルーム管理・録音などの仕様差には別途対応が必要です。

人同士の通話とAI音声チャットで分かれる実装

「ボイスチャット」には、人同士の通話と、AI と音声で対話する機能の両方が含まれ、実装は途中から分かれます。AI との対話では音声を相手の端末ではなく推論サーバへ届けるため、P2P の前提が成り立ちません。OpenAI の Realtime API は WebRTC でブラウザから直接接続する方式を用意しており、この場合はOpenAIのサーバがWebRTC接続の相手ピアになります。ブラウザが作った SDP を自社のサーバ経由で交換するか、短命のキーだけをブラウザに渡すかのどちらかで、標準の API キーはサーバ側に置きます。接続方式の違いと実装手順はリアルタイムAPI(Realtime API)とは?接続方式と実装手順【2026年8月版】にまとめました。

人同士の通話にAIの文字起こしや翻訳を足す場合は、SFU にエージェントを参加者として入れる構成が組みやすく、LiveKit Agents はこの用途の枠組みです。業務の会話を認識・要約する仕組みまで作り込む場合は、AI音声認識システム開発のように音声認識側を専門に設計する選択肢もあります。ブラウザ内蔵の Web Speech API で済ませる案は、ブラウザごとに対応が分かれるため、全ブラウザ対応のサービスでは単独の実装手段にできません。

実装後に出る不具合の切り分け(音が出ない・エコー・繋がらない)

相手の音声が鳴らないときの自動再生対策

接続は確立し、統計上もパケットが届いているのに音が出ないとき、原因の多くはブラウザの自動再生制限です。Chrome の自動再生ポリシーは、ユーザーがそのサイトをクリックやタップで操作する前の音声付き自動再生を、過去の再生履歴やPWAのインストールといった例外を除いて許可しません。iOS Safariにも自動再生制限がありますが、MediaStreamの再生には、ページがすでに音声・映像を取得中の場合などの例外があります。

// 例5: 相手の音声が鳴らないときの再生リトライ
export function attachRemoteAudio(el: HTMLAudioElement, stream: MediaStream): void {
  el.srcObject = stream;
  el.autoplay = true;
  el.play().catch(() => {
    const resume = () => {
      void el.play();
      document.removeEventListener("click", resume);
      document.removeEventListener("touchend", resume);
    };
    document.addEventListener("click", resume, { once: true });
    document.addEventListener("touchend", resume, { once: true });
  });
}

再生失敗を握りつぶさず、次のユーザー操作で再試行するのが定石です。「通話に参加」ボタンからマイク取得と再生を開始すると制限を回避しやすくなります。ただし、相手音声の到着は非同期なので、play()の失敗を処理し、再生ボタンで再試行できる設計も必要です。なお playsInline は映像要素の属性で、HTMLAudioElement には存在しません。音声のみの実装で付けると TypeScript では型エラーになります。

ハウリング・エコーの原因と対策

同じ部屋で複数人がスピーカーを使うと、互いの音を拾い合ってハウリングが起きます。例1の echoCancellation は、自端末のスピーカー出力を基準に打ち消す仕組みです。外付けスピーカーの遅延や、自端末の再生信号に含まれない他端末の音は、十分に除去できない場合があります。同じ部屋から複数人が参加する運用なら、ヘッドセット着用を前提にするか、1台を代表にして他はミュートにするルールを UI で示すのが確実です。

ICE失敗時の切り分け

繋がらないときは、まず iceConnectionState を見ます。failed で止まっていれば経路の問題なので、TURN の資格情報が正しいか、TURN サーバに 3478(TLS なら 5349)で到達できるかを確認します。checkingが続く場合は候補ペアの接続確認が完了していないため、ネットワーク到達性とシグナリングの両方を調べ、offer と answer の交換順序や、candidate が相手に届いているかをサーバのログで追います。Chrome なら chrome://webrtc-internals を開くと、候補の種類(host・srflx・relay)と選ばれたペアが一覧できます。relay 候補が1つも取得できていなければ、TURNの設定漏れ、認証失敗、割り当て失敗、通信経路の遮断を確認します。選択された候補ペアがrelayでないだけなら、TURN障害とは限りません。

よくある質問

ボイスチャットは自作できますか?

ブラウザで2人が話す通話なら自作できます。必要なのは WebRTC の標準 API と、SDP や ICE candidate を中継するシグナリングサーバで、本記事の例1〜例4がその中核部分です。社内ネットワークやモバイル回線から使うなら TURN サーバも用意してください。3人以上や Unity のゲームでは SDK を使ったほうが工数を抑えられます。

ボイスチャットの実装にサーバーは必要ですか?

必要です。音声そのものは端末同士で直接やり取りできますが、相手を見つけて接続情報を交換するシグナリングサーバは WebRTC の規格の外にあり、自前で用意します。直接つながらない環境向けに TURN サーバ、多人数向けに SFU も加わります。SDK や SaaS を使えば、これらは提供元のサーバが担います。

ボイスチャットSDKは無料で使えますか?

無料枠の範囲なら使えますが、条件はSDKごとに違います。Vivox は同時接続5,000まで公開タイトルでも無料、Agora は毎月10,000分まで無料、LiveKit Cloud は毎月5,000分まで無料です。Photon Voice の無料20 CCU と SkyWay の Free プランは開発・検証用で、商用の公開には有料プランが要ります。

WebRTCとWebSocketはどちらを使いますか?

併用します。音声データの転送は WebRTC が担い、接続確立前の SDP と ICE candidate の受け渡しに WebSocket を使う構成が一般的です。両者の性質の違いはSSEとWebSocketの違いと使い分け|WebRTC・WebTransportまで性能比較【2026年10月】で比較しています。

Reactのチャットアプリに音声通話を追加できますか?

追加できます。本記事のコードは UI ライブラリに依存していないため、RTCPeerConnection の生成と破棄をコンポーネントのライフサイクルに合わせるだけで組み込めます。再レンダリングのたびに接続を作り直さないよう ref で保持し、アンマウント時に close() とトラックの stop() を呼んでマイクを解放してください。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

RELATED POSTS 関連記事

目次