---
title: "Twilio Preflightとは？通話品質の事前診断の実装と結果の読み方、Voice SDKの推奨設定"
url: "https://www.issoh.co.jp/tech/details/9758/"
published: 2025-11-12
updated: 2026-10-31
categories: ["プラットフォーム"]
publisher: "株式会社一創"
---

# Twilio Preflightとは？通話品質の事前診断の実装と結果の読み方、Voice SDKの推奨設定

Twilio Preflightは、Twilioの**Voice SDK**（JavaScript・iOS・Android・React Native）と**Video JavaScript SDK**に入っている、通話を始める前に回線を診断するAPIです。本番の通話やルーム参加の前にTwilioのサーバーと短いテスト接続を張り、RTT（往復遅延）・ジッタ・パケットロスなどを返します。Video版は必ずTURN中継の経路で測り、中継に使ったプロトコル（udp・tcp・tls）が分かります。

同じ「Preflight」という名前でも、Voice版とVideo版では接続先も使うメディアも違います。この記事では、2026年10月31日時点の最新版（`@twilio/voice-sdk` 2.18.5、`twilio-video` 2.36.1、iOS 6.13.8、Android 6.10.5、React Native 1.8.1）のソースと公式ドキュメントで確かめた仕様をもとに、準備、実装、モバイルSDKでの違い、結果の読み方、通話品質を上げるVoice SDKの設定を順に説明します。

## まとめ：Twilio Preflightの要点

- **Voice版**は `Device.runPreflight(token, options)`。トークンに紐づくTwiMLアプリへ実際にテスト通話をかけ、MOS・RTT・ジッタと5段階の `callQuality` を返します。
- **Video版**は `runPreflight(token, options)`（2.16.0でPublic Beta、2.20.0でGA）。合成した音声・映像をTURN経由で自分に折り返し、既定10秒で測ります。**カメラ・マイクと帯域は検査しません。**
- Voice版の `callQuality` は区分に隙間があり、平均MOSが4.0超4.1未満、3.6超3.7未満、3.0超3.1未満だと `degraded` になります。`stats.mos.average` に自分で閾値を当てれば、この隙間を避けられます。
- Video版の `stats.jitter` は**秒**で返ります（Voice版はミリ秒）。単位をそろえないと閾値の判定を誤ります。
- iOS・Android・React NativeのVoice SDKにも `PreflightTest` があります。iOS 6.13.8とAndroid 6.10.5（2026年10月）で、統計を1件も取れずに終わったテストのクラッシュが直りました。サンプルが空のレポートは「品質が最悪」ではなく「測れていない」と扱います。
- 通話品質を上げる設定は、Opus優先（`codecPreferences: ['opus', 'pcmu']`）、近いedgeの指定、宛先UDP 10000〜60000番への送信許可の3つから手を付けます。

## Voice版とVideo版の違い｜測り方・返る指標・前提条件

まず両者の違いを押さえます。Voice版はTwilioへの実際の発信を伴い、Video版はブラウザ内で2本のピア接続をつないで折り返すだけです。

| 項目               | Voice版                     | Video版                |
| ---------------- | -------------------------- | --------------------- |
| 呼び出し             | `Device.runPreflight()`    | `runPreflight()`      |
| パッケージ（2026-10時点） | @twilio/voice-sdk 2.18.5   | twilio-video 2.36.1   |
| 接続先              | トークンのTwiMLアプリ              | シグナリングとTURN           |
| 使う音声・映像          | 実マイク（既定）                   | 合成トラック（640×480）       |
| テストの長さ           | TwiMLの内容で決まる               | 既定10秒（`duration`）     |
| 返る統計             | mos・rtt・jitter（ロス率はtotals） | rtt・jitter・packetLoss |
| jitterの単位        | ミリ秒                        | 秒                     |
| 品質の区分            | `callQuality`（5段階）         | なし（自前で判定）             |
| 失敗時の部分レポート       | 渡らない                       | 渡る                    |

### Voice版：TwiMLアプリへのテスト発信と測定条件

Voice版は内部で `preflight: true` を付けた `Device` を作って登録し、通話パラメータ（`params`）を付けない `device.connect()` で発信します。つまり、トークンの `outgoingApplicationSid` が指すTwiMLアプリの内容がそのままテスト通話の中身になります。品質はWebRTCの統計から1秒ごとに取るサンプルで計算し、MOSが最初に正の値になったサンプル以降の平均・最大・最小をレポートに入れます。

オプションの既定値はソースで次のとおりです。`codecPreferences` は `['pcmu', 'opus']`、`edge` は `'roaming'`、`fakeMicInput` は `false`、`signalingTimeoutMs` は10000（ミリ秒）。`fakeMicInput: true` にするとマイクの代わりにSDKのカウベル音源を流し、20秒（`ECHO_TEST_DURATION`）で自動切断します。この設定はChromeでだけ使え、他のブラウザでは致命的なエラーで失敗します。本番の `Device` でOpusや `tokyo` を指定しているなら、Preflightにも同じ値を渡さないと、本番と違う条件を測ることになります。PreflightTestの `edge` は文字列だけで配列を受け付けないため、本番で `['tokyo', 'singapore']` を指定しているなら先頭の `'tokyo'` を渡します。

レポートの `isTurnRequired` は、選ばれたICE候補のどちらかが中継（relay）なら `true` になります。リファレンスは `iceServers` にSTUNとTURNを両方渡せばTURNの要否を判定できると説明していますが、2.18.5のソースでは `iceServers` は `Device` にも `connect()` にも渡されず、ログ出力にしか使われていません。この値を判断材料にする前に、自分の環境で `selectedIceCandidatePairStats` の中身を確かめてください。

仕様の一次情報は[公式リファレンスのTwilio.PreflightTest](https://www.twilio.com/docs/voice/sdks/javascript/twiliopreflighttest)と、版タグ付きの[2.18.5のpreflight.ts](https://github.com/twilio/twilio-voice.js/blob/2.18.5/lib/twilio/preflight/preflight.ts)です。iOS・Android・React NativeのVoice SDKにも同じ仕組みの `PreflightTest` があり、違いは後述の「iOS・Android・React NativeのPreflightTest」の章でまとめます。

### Video版：合成トラックのTURN経由の折り返し測定

Video版はブラウザ内に送信側と受信側の `RTCPeerConnection` を2本作り、合成した音声と640×480の映像を送信側から流して受信側で受け取れるかを確かめます。[Video版の公式ドキュメント](https://www.twilio.com/docs/video/troubleshooting/preflight-api)は「Preflight用のテストルームにつなぐ」と説明していますが、2.36.1のソース（Preflightの処理は2.36.0と同一）が行うのは、シグナリングサーバーからTURNの認証情報を取得し、ブラウザ内の2本の接続で折り返すところまでです。送信側は `iceTransportPolicy: 'relay'` に固定されているため、**測っているのはTwilioのTURN中継を通る経路**です（ICE・STUN・TURNの役割は[WebRTCの仕組みの解説](/tech/details/15817/)を参照）。公式ドキュメントは、Preflightが成功すれば直接接続やSTUNも使える本番の通話はつながる、失敗すればそのネットワークは中継の通信まで遮断している、と説明しています。

見落としやすいのは検査しない範囲です。最初の進捗 `mediaAcquired` は合成トラックを作れたというブラウザ機能の確認で、ユーザーのカメラやマイクには触れません。帯域の上限も測りません。帯域を見たいときは、公式ドキュメントが案内している RTC Diagnostics SDK（`@twilio/rtc-diagnostics`）の `testMediaConnectionBitrate` を併用します。Video版は2.16.0（2021年8月11日）でPublic Betaとして入ったときに無料になり、2.20.0（2022年2月10日）でGAになりました。

## Voice版Preflightの準備と実装手順

Voice版で用意するものは、テスト通話用のTwiMLアプリ、そのSIDを入れたアクセストークン、クライアントのコードの3つです。

### TwiMLアプリの用意：Record/PlaybackかEcho

公式ドキュメントは、マイクの音声を録音してブラウザへ再生し返すTwiMLアプリを推奨しています。TwiML Binを2つ作ります。先に再生用（Playback）を作り、そのURLを録音用（Record）の `action` に入れます。

```
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Record a message in 3, 2, 1</Say>
  <Record maxLength="5" action="https://handler.twilio.com/twiml/EHxxxxxxxx"></Record>
  <Say>Did not detect a message to record</Say>
</Response>
```

```
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>You said:</Say>
  <Play loop="1">{{RecordingUrl}}</Play>
  <Say>Now waiting for a few seconds to gather audio performance metrics.</Say>
  <Pause length="3"/>
  <Say>Hanging up now.</Say>
</Response>
```

次にConsoleの **Builder tools > TwiML host & config > TwiML apps** でTwiMLアプリを作り、VoiceタブのRequest URLにRecord側のBinのURLを入れてSID（AP…）を控えます。`fakeMicInput: true` で使う場合は、`<Echo/>` だけのBinを指すTwiMLアプリを別に作ります。手作業を省きたいなら、公式が案内している `twilio/rtc-diagnostics-react-app` のサンプルを使う方法もあります。

この構成では、録音最大5秒、再生、3秒の待機を含めて、テスト通話は25秒前後かかります（公式リファレンスのレポート例で `testTiming.duration` は25,309ms）。テストのたびに録音がアカウントに残り、無音の録音も既定で保存されます。残したくなければREST APIで定期的に削除するか、Echo構成を使います。

### アクセストークンの発行：VoiceGrantへのTwiMLアプリSIDの設定

トークンはサーバー側で発行し、HTTPのルートから `{ token }` の形で返します。次のコードは、twilio 6.2.0・express 5.3.0（Node.js 26）でダミーのSIDを入れ、`requireLogin` を素通しの仮実装に置き換えて起動したルートを叩くと、JWTの中身が `{"voice":{"outgoing":{"application_sid":"AP…"}}}` と `{"video":{}}` になることを2026年10月31日に確かめたものです。

```
// token-server.mjs（Node.js 20以降・twilio 6.x・express 5）
import express from 'express';
import twilio from 'twilio';

const { AccessToken } = twilio.jwt;
const { VoiceGrant, VideoGrant } = AccessToken;

export function createPreflightToken(kind) {
  const token = new AccessToken(
    process.env.TWILIO_ACCOUNT_SID,
    process.env.TWILIO_API_KEY_SID,
    process.env.TWILIO_API_KEY_SECRET,
    // identityは必須。テスト専用と分かる値にしておく
    { identity: `preflight-${Date.now()}`, ttl: 600 },
  );

  if (kind === 'voice') {
    // Record/Playback（またはEcho）のTwiMLアプリを指す
    token.addGrant(new VoiceGrant({
      outgoingApplicationSid: process.env.PREFLIGHT_TWIML_APP_SID,
    }));
  } else {
    // Video版はroomを指定しても無視される
    token.addGrant(new VideoGrant());
  }
  return token.toJwt();
}

const app = express();
// Voice用トークンは課金される発信に使えるので、ログイン済みのユーザーにだけ返す
// requireLoginはアプリ側の認証ミドルウェア（定義は省略）
app.get('/token/preflight', requireLogin, (req, res) => {
  res.json({ token: createPreflightToken(req.query.kind) });
});
```

### クライアントの実装：completed・warning・failedの受け取り方

```
import { Device } from '@twilio/voice-sdk';

export async function runVoicePreflight() {
  const res = await fetch('/token/preflight?kind=voice');
  // fetchの戻り値はResponse。トークン文字列を取り出してから渡す
  const { token } = await res.json();

  const preflight = Device.runPreflight(token, {
    // 本番のDeviceと同じ設定で測る（既定はpcmu優先・roaming）
    codecPreferences: ['opus', 'pcmu'],
    // Preflightのedgeは文字列だけ。配列は渡せない
    edge: 'tokyo',
  });

  // warningは警告オブジェクト1つが渡される（name・description・rtcWarning）
  preflight.on('warning', (warning) => {
    console.warn(warning.name, warning.rtcWarning?.values);
  });

  preflight.on('completed', (report) => {
    // MOSを計算できるサンプルが1つも無いとstatsはundefinedになる
    if (!report.stats) {
      console.warn('MOSを計算できるサンプルがありません', report.samples.length);
      return;
    }
    const { mos, rtt, jitter } = report.stats;
    console.log({
      callQuality: report.callQuality,
      mos: mos?.average,
      rttMs: rtt?.average,
      jitterMs: jitter?.average,
      // ロス率（%）はstatsでなくtotalsにある
      lossPct: report.totals?.packetsLostFraction,
      isTurnRequired: report.isTurnRequired,
      warnings: report.warnings.map((w) => w.name),
    });
  });

  // Voice版のfailedに渡るのはerrorだけ（部分レポートは渡らない）
  preflight.on('failed', (error) => {
    console.error(error.code, error.message);
  });

  return preflight;
}
```

起きやすい誤りは2つです。`fetch()` の戻り値（`Response`）をそのままトークンとして渡すことと、Voice版の `failed` で `(error, report)` を受け取ろうとすることです。部分レポートを渡すのはVideo版だけです。

`warning` も公式リファレンスと実装が食い違っています。リファレンスは `handler(name, warning)` と書いていますが、2.18.5のソースは `{ name, description, rtcWarning }` のオブジェクトを1つ渡すだけです。リファレンスどおり2引数で書くと、第1引数に入ってくるのは文字列ではなくオブジェクトになります。上の例のように `warning.name` で読めば、このずれに左右されません。

## Video版runPreflightの実装と失敗段階の切り分け

Video版のトークンには `VideoGrant` が要ります。公式ドキュメントによれば、テスト用のルームはPreflightが用意するため、`room` を指定しても無視されます。`identity` は必須ですが、値は何でも構いません。

```
import { runPreflight } from 'twilio-video';

export async function runVideoPreflight() {
  const res = await fetch('/token/preflight?kind=video');
  const { token } = await res.json();

  const preflight = runPreflight(token, { region: 'gll', duration: 10000 });
  const steps = [];

  preflight.on('progress', (step) => steps.push(step));

  preflight.on('completed', (report) => {
    const { rtt, jitter, packetLoss } = report.stats;
    const pair = report.selectedIceCandidatePairStats;
    console.log({
      rttMs: rtt?.average,
      // Video版のjitterは秒。ミリ秒にそろえる
      jitterMs: jitter ? jitter.average * 1000 : undefined,
      packetLossPct: packetLoss?.average,
      relayProtocol: pair?.localCandidate.relayProtocol,
      connectMs: report.networkTiming.connect?.duration,
    });
  });

  // Video版のfailedにはerrorと部分レポートが渡る
  preflight.on('failed', (error, report) => {
    console.error(error.message, 'reached:', steps.at(-1), report?.progressEvents);
  });

  return preflight;
}
```

失敗したときは、どの `progress` まで進んだかで原因の層を切り分けられます。公式ドキュメントの対応表をまとめると次のとおりです。

| 止まった位置                        | エラーコード | 疑う箇所                |
| ----------------------------- | ------ | ------------------- |
| `mediaAcquired` より前           | なし     | ブラウザのAPI（回線ではない）    |
| `mediaAcquired` 〜 `connected` | 53000  | WSS（443番）のプロキシ・FW   |
| `connected` 〜 `mediaStarted`  | 53405  | TURNのUDP/TCPポート、DPI |

なお、`dtlsConnected` はSafariで、`peerConnectionConnected` はFirefoxで発生しません。この2つが来ないことだけを理由に失敗と判定しないでください。

## iOS・Android・React NativeのPreflightTest

モバイルのVoice SDKでも、準備はJavaScript版と同じです。テスト通話を受けるTwiMLアプリと、そのSIDを `outgoingApplicationSid` に入れたアクセストークンを用意します。[モバイルSDK向けの公式ドキュメント](https://www.twilio.com/docs/voice/sdks/mobile-preflight-test)の手順も、録音用と再生用のTwiML Binを作る構成です。違うのは呼び出し方と、結果の渡し方です。

### モバイルVoice SDK別の呼び出し方とイベント名

| SDK          | 呼び出し                                                 | 警告の受け取り                              | 追加された版             |
| ------------ | ---------------------------------------------------- | ------------------------------------ | ------------------ |
| iOS（Swift）   | `TwilioVoiceSDK.runPreflightTest(options:delegate:)` | `preflightDidReceiveQualityWarnings` | 6.12.0（2024-10-21） |
| Android      | `Voice.runPreflight(context, accessToken, listener)` | `onPreflightWarning`                 | 6.7.0（2024-10-21）  |
| React Native | `voice.runPreflight(token, options)`                 | `PreflightTest.Event.QualityWarning` | 1.7.0（2025-10-08）  |

JavaScript版との違いは3つあります。警告は1件ずつではなく、現在出ている警告の集合と直前の集合の2つが渡されます。新しく出た警告と解消した警告は、2つの集合の差で判定します。`stop()` で止めると、テストはエラーコード31008（通話のキャンセル）の `failed` で終わります。レポートには `isTurnRequired` のほか、テスト中に解消した警告を並べる `warningsCleared` が入ります。

React Nativeは、公式ドキュメントのコード例をそのまま写すと型エラーになります。例にある `PreflightTest.Event.Warning` と `PreflightTest.Sample` は、1.8.1のソース（`src/PreflightTest.ts`）の列挙型に存在しません。正しくは `PreflightTest.Event.QualityWarning`（値は `'qualityWarning'`）と `PreflightTest.Event.Sample` です。`runPreflight` のオプションも `iceServers`・`iceTransportPolicy`・`preferredAudioCodecs` の3つだけです。

`callQuality` の表し方もSDKごとに違います。モバイル向けドキュメントは0（平均MOS 4.2超）から4（3.0以下）の5段階で説明しており、区分の境目はJavaScript版と同じで、4.0超4.1未満などの隙間もそのまま残っています。React Nativeはネイティブ側が返す `"Excellent"` などの値を `'excellent'` から `'degraded'` の小文字の文字列に変換し、値が無ければ `null` を返します。iOSとAndroidの本体はソースが公開されておらず、隙間に入った値がどの区分になるかを確かめられません。判定は平均MOS（iOS・Androidのレポートでは `networkStats.mos.average`、React Nativeでは名前が変わって `stats.mos.average`）に自分で閾値を当てる方が確実です。

### 2026年10月の修正：統計が1件も無いレポート

[iOS 6.13.8（2026年10月14日）](https://www.twilio.com/docs/voice/sdks/ios/changelog)と[Android 6.10.5（2026年10月19日）](https://www.twilio.com/docs/voice/sdks/android/3x-changelog)で、統計のサンプルを1件も取る前にテストが終わったときのクラッシュが修正されました。接続直後に相手側から切断された場合などに、SDKが空のサンプルからジッタ・MOS・RTTの最小値と最大値を計算しようとして落ちていた不具合です。

修正前の版では、こうしたレポートの平均・最小・最大に不定の値が入ることがありました。修正後は0になります。iOSでは `samples` が空のとき `networkStats` の値がすべて0、`callQuality` が `nil` です。MOS 0を「品質が最悪」と読むと、測れていないだけのテストを不合格にしてしまいます。品質の値を読む前に `samples` の件数を確かめてください。JavaScript版も、MOSが正の値になったサンプルが無いと `report.stats` 自体が `undefined` になります。

更新するときの注意もあります。iOS 6.13.8は `x86_64` のシミュレーターに対応しなくなり、シミュレーターでの実行にはApple siliconのMacが必要です。React Nativeの修正点は[React Native SDKの変更履歴](https://github.com/twilio/twilio-voice-react-native/blob/main/CHANGELOG.md)で確認します。

## 結果の読み方｜callQualityの区分と警告の閾値

### Voice版callQualityの5段階と区分の隙間

Voice版は平均MOSを次の5段階に変換して `report.callQuality` に入れます。数値は2.18.5のソース（`_getCallQuality`）と公式リファレンスで一致しています。

| callQuality | 平均MOSの範囲       |
| ----------- | -------------- |
| excellent   | 4.2超           |
| great       | 4.1以上4.2以下     |
| good        | 3.7以上4.0以下     |
| fair        | 3.1以上3.6以下     |
| degraded    | 上記以外（3.0以下を想定） |

問題は表の継ぎ目です。平均MOSは有効数字5桁に丸めた値（`toPrecision(5)`）なので、4.0532や3.6500のような値が普通に出ます。ところが4.0超4.1未満と3.6超3.7未満はどの区分の条件にも当てはまらず、最後の `else` で `degraded` に落ちます。同じ条件式をNode.jsで動かすと、MOS 4.05は `degraded`、4.25は `excellent` でした。`callQuality === 'degraded'` だけを見て通話をブロックすると、品質が「good」相当のユーザーまで止めてしまいます。判定は `report.stats.mos.average` に自分で閾値を当てれば、区分の隙間に引っかかりません。

### MOSの算出方法：RTT・ジッタ・ロスから計算する推定値

このMOSは音声を聞いて採点した値ではありません。SDKの `mos.ts` は、RTT、ジッタ、パケットロス率からE-modelを簡略化した式でR値を出し、MOSに変換しています。RTT・ジッタ・ロスがすべて0でも約4.43が上限で、5には届きません。同じ式に値を入れると、RTTとロスの効き方が分かります。

| RTT   | ジッタ  | ロス  | MOS  | callQuality |
| ----- | ---- | --- | ---- | ----------- |
| 69ms  | 1ms  | 0%  | 4.40 | excellent   |
| 200ms | 10ms | 0%  | 4.16 | great       |
| 250ms | 10ms | 0%  | 3.98 | good        |
| 300ms | 20ms | 0%  | 3.68 | degraded    |
| 100ms | 10ms | 5%  | 3.99 | good        |
| 100ms | 10ms | 10% | 3.43 | fair        |

RTT 300msの行は、MOS 3.68が区分の隙間に入って `degraded` になった例です。上の表は2.18.5の式をNode.jsで再現した試算で、実際のレポートは1秒ごとのサンプルの平均になります。

### Voice版の警告名と閾値

テスト中に出る警告は、本番の通話で `call.on('warning')` に出るものと同じ仕組み（`StatsMonitor`）から来ます。2.18.5の既定値は次のとおりです。RTT・ジッタ・MOS・ロス率は、1秒ごとのサンプルを直近10個まで保持し、そのうち3個以上が閾値を超えたときに発生します。

| 警告名                                 | 既定の条件                                        |
| ----------------------------------- | -------------------------------------------- |
| high-rtt                            | RTTが400ms超                                   |
| high-jitter                         | ジッタが30ms超                                    |
| low-mos                             | MOSが3未満                                      |
| high-packet-loss                    | ロス率が1%超                                      |
| high-packets-lost-fraction          | 直近7サンプルの平均ロス率が3%超                            |
| low-bytes-received / low-bytes-sent | 直近3サンプルで受信・送信が途絶                             |
| constant-audio-input-level          | 入力音量がほぼ変化しない（マイクの故障や入力デバイスの選び間違い等。ミュート中は出ない） |

Voice Insightsのドキュメントは同じ警告を「直近5サンプル中3回」「MOS 3.5未満」と説明していますが、JavaScript SDK 2.18.5のソースの既定値はMOS 3未満です。ブラウザで受け取る警告の条件はソースの値で考えます。

この閾値は「警告を出す線」で、品質が良いと言える線ではありません。[公式のネットワーク要件](https://www.twilio.com/docs/voice/sdks/network-connectivity-requirements)が示す目安はRTT 200ms未満、ジッタ30ms未満、ロス3%未満です。警告が出なかったことは、この目安を満たした証拠にはなりません。

### 利用者に見せる判定：4指標のうち1つでも外れたら注意

利用者に結果を見せるなら、`callQuality` の5段階をそのまま出すより、RTT・ジッタ・ロス・MOSのどれが目安を外れたかを伝える方が対処につながります。RTTが原因なら回線の距離やVPN、ロスが原因ならWi-Fiの電波や混雑と、取るべき行動が変わるためです。次のコードはVoice版のレポートを、上の公式の目安とVoice Insightsのlow-mos（3.5未満）で判定します。

```
// 公式のネットワーク要件の目安：RTT 200ms未満・ジッタ30ms未満・ロス3%未満
const LIMITS = { rtt: 200, jitter: 30, loss: 3, mos: 3.5 };

export function judgePreflight(report) {
  const { stats, totals } = report;
  // MOSが正になったサンプルが1つも無いと stats は undefined
  if (!stats || !totals) return { ok: false, reasons: ['no-samples'] };
  const values = [stats.rtt?.average, stats.jitter?.average, stats.mos?.average, totals.packetsLostFraction];
  if (!values.every(Number.isFinite)) return { ok: false, reasons: ['invalid-stats'] };

  const reasons = [];
  if (stats.rtt.average >= LIMITS.rtt) reasons.push('rtt');
  if (stats.jitter.average >= LIMITS.jitter) reasons.push('jitter');
  // totals.packetsLostFraction はテスト全体の累計（%）
  if (totals.packetsLostFraction >= LIMITS.loss) reasons.push('loss');
  if (stats.mos.average < LIMITS.mos) reasons.push('mos');
  return { ok: reasons.length === 0, reasons };
}
```

Voice版の `report.stats` にはロス率の集計が無いため、`report.totals`（最後のサンプル時点の累計）の `packetsLostFraction` を使います。単位はパーセントです。受信パケットが1つも無いとき、2.18.5のソースはこの値を100にします。Node.jsで前の表と同じ値を入れると、RTT 300ms・MOS 3.68は `['rtt']`、RTT 100ms・ロス10%・MOS 3.43は `['loss', 'mos']`、統計なしは `['no-samples']`、値がNaNのときは `['invalid-stats']` を返しました。この2つは「回線が悪い」ではなく「もう一度測る」と表示します。

### Video版statsの単位と目安

Video版の `stats` はRTTがミリ秒、パケットロスがパーセント（0〜100）、**ジッタは秒**です。0.015は15ミリ秒を意味します。公式ドキュメントの目安は次のとおりです。

| 指標        | 良好      | 許容          | 不良     |
| --------- | ------- | ----------- | ------ |
| RTT（ms）   | 100未満   | 100〜300     | 300超   |
| ジッタ（秒）    | 0.030未満 | 0.030〜0.100 | 0.100超 |
| パケットロス（%） | 1未満     | 1〜5         | 5超     |

あわせて `selectedIceCandidatePairStats.localCandidate.relayProtocol` を見ます。`udp` なら通常の経路、`tcp` はUDPが塞がれている、`tls` はUDPと素のTCPの両方が塞がれていて最も遅延が大きい経路です。社内ネットワークで `tls` が出たら、ユーザーに回線の切り替えを促すよりも、情報システム部門にTwilioのメディア用IPとポートを許可してもらう方が根本的な対策になります。接続にかかった時間は `networkTiming.connect.duration` で見ます。公式の目安は800〜1000msで、5000msを超えるなら遠いリージョンにつながっている可能性があります。

## Voice SDKの通話品質を上げる推奨構成

Preflightで問題が見つかった後に手を入れるのは、多くの場合アプリ側の `Device` の設定とネットワークの許可設定です。公式のベストプラクティスとネットワーク要件から、設定箇所ごとにまとめます。

```
import { Device } from '@twilio/voice-sdk';

export function createDevice(token) {
  return new Device(token, {
    // Opusを優先。帯域を絞るなら音声向けは8000〜40000bps
    codecPreferences: ['opus', 'pcmu'],
    maxAverageBitrate: 24000,
    // 東京を優先し、落ちたらシンガポールへ
    edge: ['tokyo', 'singapore'],
    // シグナリング断のとき同じedgeへの再接続を最大30秒試す
    maxCallSignalingTimeoutMs: 30000,
  });
}
```

### コーデック設定：PCMU優先からOpus優先への変更

JavaScript SDKの `codecPreferences` の既定は `["pcmu", "opus"]` です。モバイルSDKはOpusが既定なのに対し、ブラウザは指定しない限りPCMU（G.711）が優先されます。公式の帯域要件はOpusが上り下り各40kbps、PCMUが各100kbps。Opusは回線が悪いときほど差が出ます。帯域が細い拠点では `maxAverageBitrate` で上限を絞れます。設定できる範囲は6000〜510000bpsで、公式は音声なら8000〜40000bpsを推奨しています。なお上のコードはJavaScriptの例で、TypeScriptでは `'opus'` の文字列ではなく `Call.Codec.Opus` のenumで書かないと型エラー（TS2322）になります。

### edgeの指定：tokyoと自動フォールバック

既定の `roaming` はGlobal Low Latency（GLL）で、DNSの応答から最も近いedgeを選ぶ仕組みです。ただしGLLが正しく働くには、途中のDNSがRFC 7871（EDNS Client Subnet）に対応し、ユーザーと同じ地域にある必要があります。海外拠点経由のVPNや海外のDNSを使っている端末は、日本にいても遠いedgeにつながることがあります。利用者がほぼ国内なら `edge: ['tokyo', 'singapore']` のように配列で指定すると、東京に優先して接続し、つながらないときは次のedgeへ自動で切り替わる構成です。`maxCallSignalingTimeoutMs` の既定は0で、この場合シグナリングが切れても通話中に再接続されないことがあるため、最大値の30000を指定しておきます。ただし0より大きくすると、その時間が過ぎるまでTwilioは通話を終了しないため、通話ステータスのコールバックで後続処理を動かしている場合は待ち時間を見込みます。

### ファイアウォールと帯域の要件

メディアは `168.86.128.0/18` へのUDP 10000〜60000番、シグナリングは `voice-js.roaming.twilio.com` などへのTCP 443番を使います。Insightsの `eventgw.twilio.com` と効果音の `sdk.twilio.com` も443番で許可が必要です。前者が塞がるとPreflightは `insights-connection-error` の警告を出し、後者が塞がると `fakeMicInput` の音源を読み込めません。Twilio側から接続を始めることはないため、許可するのは送信方向の通信とその戻りの通信です（家庭や社内のルーターが戻りの通信を通す仕組みは[NATの解説](/tech/details/13552/)を参照）。送信元ポートはOSのエフェメラルポート（多くは1024〜65535番）から選ばれるため、送信元ポートを絞るルールはTwilioへの通信を止めます。UDPを通せないネットワークでは、公式はNetwork Traversal Service（NTS）のTURNを使ってTCPやTLSでメディアを中継する構成を案内しています。TURNの利用にはNTSの料金が別にかかり、遅延も増えるため、まずはUDPの許可を検討します。Chromeに限ってはDSCPで音声パケットに優先度の印を付けられますが、ルーター側がDSCPに対応している必要があります。

### 管理下のChromeでのWebRtcIPHandling

コールセンターのように端末をChrome Enterpriseで管理しているなら、ポリシー `WebRtcIPHandling` を `default_public_interface_only` にする設定が公式のベストプラクティスに載っています。通話中のネットワーク切り替えでブラウザが別のインターフェースを使おうとして起きる不具合を減らすためのものです。

### 本番の通話を監視するVoice Insights

Preflightで分かるのは通話前の状態だけです。通話中の劣化は、同じ警告の仕組みを使う `call.on('warning')` と、通話後の統計を集めるVoice Insights（ダッシュボードとREST API）で追います。Preflightの結果と本番の通話のCall SIDを紐付けて保存しておくと、問い合わせが来たときに「通話前から回線が悪かったのか」を切り分けられます。

## Preflightを入れる場面と入れない場面

Preflightは全ての通話の前に毎回走らせるものではありません。Voice版はTwiMLアプリへの発信を伴い、Record/Playback構成なら25秒前後かかります。発信ボタンのたびに挟めば、ユーザーの待ち時間が増えるだけです。

- **入れるべき場面**：コールセンターのオペレーターがブラウザで受発信する構成では、ログイン時や勤務開始時に1回走らせ、結果をサーバーに保存します。オンライン診療や面接のように、開始時刻が決まっていてやり直しのきかないビデオ通話では、待合画面でVideo版を走らせます。
- **入れなくてよい場面**：社内の固定回線だけで使う内線アプリでは、毎回の通話前診断は省略できます。ただし、導入時に加えてネットワーク設定や端末の変更時、品質問題の発生時には再測定します。
- **Preflightだけでは足りない場面**：ビデオ通話でカメラ・マイクの不調を事前に見つけたいなら、Video版では検査できないため、`getUserMedia` による機器の確認画面を別に用意します。

結果を受けて通話をブロックするかどうかは、`callQuality` ではなく前述の判定コードのように平均MOSと各指標で決めます。区分の隙間があるため、`degraded` の一律ブロックは避けてください。

## Preflightでよくあるつまずきと対処

- **Chrome 141以降でサンプルにNaNが入る**：ICE接続前は `getStats()` に `outbound-rtp` が含まれなくなった影響で、`bytesSent` などが `NaN` や `undefined` になっていました。Voice SDK 2.18.3（2026年5月11日）で修正済みです。古い版を使っているなら更新します。
- **10秒で53000エラーになる**：Voice版はWebSocketの接続を `signalingTimeoutMs`（既定10000ms）待って失敗にします。プロキシ配下では、まずTCP 443番でTwilioのシグナリングホストに届くかを確認します。
- **録音の案内が何度も繰り返される**：Record側のBinで `action` を省略すると、`<Record>` は録音後に同じBinを読み直すため、案内がループします。`action` にPlayback側のBinのURLを入れます。
- **「Twilio Videoは終了する」という古い情報**：Twilioは2024年3月にProgrammable Videoの終了を告知しましたが、2024年10月21日に撤回しています。[twilio-video の変更履歴](https://github.com/twilio/twilio-video.js/blob/master/CHANGELOG.md)では2026年10月9日にも2.36.1が出ており、Video版Preflightも引き続き使えます。

## よくある質問

### Twilio Preflightとは何ですか？

TwilioのVoice SDKとVideo JavaScript SDKに入っている、通話前に回線を診断するAPIです。ブラウザのVoice版は `Device.runPreflight()`、Video版は `runPreflight()`、iOS・Android・React Nativeは各SDKの `runPreflight` 系のメソッドで呼び出し、RTT・ジッタ・パケットロスやTURN中継の要否を返します。

### Preflightテストは無料ですか？

Video版は、Public Betaとして入った2.16.0のリリースノートで無料と明記されています。Voice版はTwiMLアプリへ実際に発信する仕組みなので、通常のブラウザ通話と同じく料金がかかる前提で見積もります。日本の料金表（2026年10月時点）ではBrowser/appが$0.0040/分で、Record/Playback構成なら録音の$0.0025/分も加わります。

### Preflightテストにはどのくらい時間がかかりますか？

Video版は接続後に既定10秒（`duration` で変更可）測ります。Voice版はTwiMLアプリの内容で決まり、`fakeMicInput: true` では20秒で自動切断されます。公式例のRecord/Playback構成では25秒前後です。

### MOSはいくつあれば通話品質に問題ありませんか？

Voice版の区分では3.7以上4.0以下が `good` です。SDKのMOSはRTT・ジッタ・ロスから計算した推定値で上限は約4.43なので、5を基準にせず、本記事の判定例では平均MOS 3.5未満を注意対象とし、RTT・ジッタ・ロスも併せて確認します。4.0超4.1未満が `degraded` になる区分の隙間にも注意します。

### VPNを使うとPreflightの結果は変わりますか？

変わります。既定の `roaming` はDNSを手掛かりに最も近いedgeを選ぶため、海外拠点経由のVPNでは遠いedgeにつながり、RTTが大きく出ます。本番で `edge: 'tokyo'` を指定しているなら、Preflightにも同じedgeを渡して測ります。

## 関連記事

- [WebRTCとは？P2Pの仕組み・ICE/STUN/TURNからSFU選定・実装・運用判断まで実装者向けに解説](/tech/details/15817/)
- [ボイスチャットの実装方法｜WebRTC自作とVivox・Photon Voice等SDKの選び方【2026年10月】](/tech/details/2292/)
- [VonageとTwilio比較｜日本の料金・提供体制・SMS制約とAPIの違い【2026年10月】](/column/details/2986/)
- [Twilio APIで音声AIを動かす4ルート｜設定手順・日本料金・日本語精度](/column/details/1939/)
- [レイテンシーとは？レスポンスタイム・スループットとの違いと測定方法・目安](/column/details/7053/)

---

出典: [Twilio Preflightとは？通話品質の事前診断の実装と結果の読み方、Voice SDKの推奨設定](<https://www.issoh.co.jp/tech/details/9758/>)（株式会社一創）
