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と、版タグ付きの2.18.5のpreflight.tsです。iOS・Android・React NativeのVoice SDKにも同じ仕組みの PreflightTest があり、違いは後述の「iOS・Android・React NativeのPreflightTest」の章でまとめます。
Video版:合成トラックのTURN経由の折り返し測定
Video版はブラウザ内に送信側と受信側の RTCPeerConnection を2本作り、合成した音声と640×480の映像を送信側から流して受信側で受け取れるかを確かめます。Video版の公式ドキュメントは「Preflight用のテストルームにつなぐ」と説明していますが、2.36.1のソース(Preflightの処理は2.36.0と同一)が行うのは、シグナリングサーバーからTURNの認証情報を取得し、ブラウザ内の2本の接続で折り返すところまでです。送信側は iceTransportPolicy: 'relay' に固定されているため、測っているのはTwilioのTURN中継を通る経路です(ICE・STUN・TURNの役割はWebRTCの仕組みの解説を参照)。公式ドキュメントは、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向けの公式ドキュメントの手順も、録音用と再生用の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日)とAndroid 6.10.5(2026年10月19日)で、統計のサンプルを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の変更履歴で確認します。
結果の読み方|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未満です。ブラウザで受け取る警告の条件はソースの値で考えます。
この閾値は「警告を出す線」で、品質が良いと言える線ではありません。公式のネットワーク要件が示す目安は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の解説を参照)。送信元ポートは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 の変更履歴では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を渡して測ります。