✈️

Twilio Preflightで通話品質を "診断" する

に公開

はじめに

こんにちは!株式会社IVRy でソフトウェアエンジニアをしているtsutouです。

VoIPで音声通話を実現している音声通話アプリにおいて「音声が聞こえない」「途切れる」といったユーザー問い合わせは、サポートチームにとって調査の難易度が高く大きな負担となります。

特に、ネットワーク環境に起因する問題は、ユーザー自身では原因を特定することが困難で、結果として多くのサポート依頼につながっていました。

そこで今回、Twilio Preflight を活用して、ユーザーが事前に自分の通話品質を診断できる機能をアイブリーアプリに公開しました。本記事では、品質判定ロジックの表現内容と、ユーザー体験向上への取り組みについて詳しく解説します。

https://www.twilio.com/en-us/changelog/twilio-voice-android-and-ios-sdks-preflight-apis-are-available-n

実際の画面

ネットワークテスト結果 環境情報
複合的にいくつかの問題がある 通話遅延が起きている

直面していた課題と背景

音声品質に関するユーザー問い合わせの問題切り分けの難しさと調査の難航が大きな課題でした。
その中でもネットワークに関する問い合わせが割合を大きく占めており、最も原因切り分けが難しい部分でした。

Twilio Preflightとは

Twilio Preflightは、Twilio Voice SDKが提供するネットワーク品質診断機能です。実際の通話を行う前に、以下の指標を測定することができます。

測定される品質指標

  • MOS (Mean Opinion Score): 音声品質の総合評価
  • RTT (Round Trip Time): ネットワークの応答時間
  • Jitter: 通信のゆらぎ
  • Packet Loss: データの損失率

これらの指標により、ユーザーの現在の通話環境を数値的に評価できます。

実行に必要な構成

Twilio Preflightは実際にTwilioサーバーへのテスト通話を実行して品質を測定します。そのため、以下の2つが必要になります。

1. TwiML App(録音・再生のエンドポイント)

Preflightは次のような処理を内部で行います:

  1. テスト通話を発信
  2. マイクから録音
  3. Twilio側で再生
  4. 再生結果を解析し、品質レポートを返す

この録音・再生を処理するために 専用の TwiML AppTwiML Bin が必要です。

https://www.twilio.com/docs/voice/sdks/mobile-preflight-test#create-a-twiml-application

2. Twilio Access Token

Access Tokenに必要な権限

  • outgoingApplicationSid: PreflightTest専用のTwiML App SID
  • identity: ユーザー識別子
  • Voice Grant: 発信権限のみでOK(incomingAllow: false

https://www.twilio.com/docs/iam/access-tokens#create-an-access-token-for-voice

バックエンドからPreflight用の Twilio Access Token を取得します

品質測定の詳細

Twilio公式ドキュメントによると、品質評価は以下のような基準で行われます。

callQuality(MOS基準)

  • 0: 平均MOSが4.2超(優秀)
  • 1: 平均MOSが4.1-4.2(良好)
  • 2: 平均MOSが3.7-4.0(良好)
  • 3: 平均MOSが3.1-3.6(標準)
  • 4: 平均MOSが3.0以下(不安定)

こういった公式の基準に基づき、実装コードで品質判定ロジックを総合的に評価しています。

Preflight はネットワークの生データを返しますが、それをそのまま表示してもユーザーには理解できません。
https://www.twilio.com/docs/voice/sdks/mobile-preflight-test#report

そこで、各指標を「どの程度の状態なのか」を判定するための品質評価をまとめ上げて表現します。

品質評価のロジック

指標 優秀 良好 標準 不安定
MOS > 4.2 4.1–4.2 3.1–4.0 < 3.1
RTT ≤ 100ms ≤ 150ms ≤ 200ms > 200ms
Jitter ≤ 15ms ≤ 20ms ≤ 30ms > 30ms
Packet Loss ≤ 1% ≤ 2% ≤ 3% > 3%

すべてが標準以上で「品質は正常」になり、どれか 1 つでも「不安定」に該当 →「品質に問題あり」になります

ユーザー体感に近づけるため、最も悪い指標を総合結果に採用し、悪い指標に何が入ってきたかでユーザーに表示するメッセージを出し分けています。

品質判定の実装

アイブリーアプリはUI/UXがFlutterで、Twilioとのやり取りはSDKのネイティブ部分が担当します。

QualityInfo.dart
// 品質レベルの定義
sealed class QualityInfo {
  const QualityInfo();
  Color get color;
  String get label;

  bool get hasProblem {
    return switch (this) {
      PoorQuality() => true,
      _ => false,
    };
  }
}

class ExcellentQuality extends QualityInfo {
  const ExcellentQuality();
  
  Color get color => IVRyColors.preflightTextGreen;
  
  String get label => '優秀';
}

class GoodQuality extends QualityInfo {
  const GoodQuality();
  
  Color get color => IVRyColors.preflightTextGreen;
  
  String get label => '良好';
}

class FairQuality extends QualityInfo {
  const FairQuality();
  
  Color get color => IVRyColors.preflightTextGreen;
  
  String get label => '標準';
}

class PoorQuality extends QualityInfo {
  const PoorQuality();
  
  Color get color => IVRyColors.preflightTextRed;
  
  String get label => '不安定';
}

// 品質評価ロジック(実際のTwilio公式ドキュメントに基づく基準値)
extension QualityCalculation on QualityInfo {
  // MOSは、callQualityに準拠する 推奨は2以上であること
  // https://www.twilio.com/docs/voice/sdks/mobile-preflight-test#report-fields
  // - 0: If the average mos is over 4.2.
  // - 1: If the average mos is between 4.1 and 4.2 both inclusive.
  // - 2: If the average mos is between 3.7 and 4.0 both inclusive.
  // - 3: If the average mos is between 3.1 and 3.6 both inclusive.
  // - 4: If the average mos is 3.0 or below.
  static QualityInfo fromMosCallQuality(num? mos) {
    if (mos == null) return const UnknownQuality();

    return switch (mos) {
      >= 4.2 => const ExcellentQuality(),
      >= 4.1 => const GoodQuality(), // 4.1–4.2未満
      >= 3.7 => const FairQuality(), // 3.7–4.1未満
      >= 3.1 => const FairQuality(), // 3.1–3.7未満
      _ => const PoorQuality(), // 3.1未満
    };
  }

  // RTT: ≤ 100ms (優秀), ≤ 150ms (良好), ≤ 200ms (標準), ≥ 200ms(不安定)
  // Twilioの推奨値: 200ms
  static QualityInfo fromRtt(num? rtt) {
    if (rtt == null) return const UnknownQuality();

    return switch (rtt) {
      <= 100 => const ExcellentQuality(),
      <= 150 => const GoodQuality(),
      <= 200 => const FairQuality(),
      _ => const PoorQuality(),
    };
  }

  // Jitter: ≤ 15ms (優秀), ≤ 20ms (良好), ≤ 30ms (標準), ≥ 30ms(不安定)
  // Twilioの推奨値: 30ms
  static QualityInfo fromJitter(num? jitter) {
    if (jitter == null) return const UnknownQuality();

    return switch (jitter) {
      <= 15 => const ExcellentQuality(),
      <= 20 => const GoodQuality(),
      <= 30 => const FairQuality(),
      _ => const PoorQuality(),
    };
  }

  // Packet Loss: ≤ 1% (優秀), ≤ 2% (良好), ≤ 3% (標準), ≥ 3% (不安定)
  // Twilioの推奨値: 3%
  static QualityInfo fromPacketLoss(num? packetLoss) {
    if (packetLoss == null) return const UnavailableQuality();

    return switch (packetLoss) {
      <= 1 => const ExcellentQuality(),
      <= 2 => const GoodQuality(),
      <= 3 => const FairQuality(),
      _ => const PoorQuality(),
    };
  }

  // 全体の品質を判定
  static OverallQualityInfo determineOverall(List<QualityInfo> qualities) {
    final worst = findWorst(qualities);

    return worst.hasProblem
        ? const ProblematicOverallQuality()
        : const NormalOverallQuality();
  }
}

結果データのモデル

実際の運用では、Twilioレスポンスの複雑なデータ構造から品質指標を抽出する処理も含まれています。

PreflightResultInfo.dart
/// Preflightの結果を表すクラス
/// ネットワークの品質を表すデータを保持し、それらの品質を評価するためのメソッドを集約している
class PreflightResultInfo {
  final double? mos;
  final double? rttMs;
  final double? jitterMs;
  final double? packetLossPercent;
  final QualityInfo mosQuality;
  final QualityInfo rttQuality;
  final QualityInfo jitterQuality;
  final QualityInfo packetLossQuality;
  final OverallQualityInfo overallQuality;

  static PreflightResultInfo fromReport(
    Map<String, dynamic>? report, {
    Map<String, dynamic>? fallbackEnv,
  }) {
    final stats = report?['networkStats'] as Map<String, dynamic>?;
    final rawWarnings = (report?['warnings'] as List?) ?? const [];

    // 複雑なTwilioレスポンスから数値を抽出
    final mos = _extractNumericAverage(stats?['mos'])?.toDouble();
    final rtt = _extractNumericAverage(stats?['rtt'])?.toDouble();
    final jitter = _extractNumericAverage(stats?['jitter'])?.toDouble();
    final packetLoss = _extractPacketLossPercent(
      networkStats: stats,
      report: report,
    );

    // 各指標の品質を評価
    final mosQ = QualityCalculation.fromMosCallQuality(mos);
    final rttQ = QualityCalculation.fromRtt(rtt);
    final jitterQ = QualityCalculation.fromJitter(jitter);
    final lossQ = QualityCalculation.fromPacketLoss(packetLoss);
    final overall = QualityCalculation.determineOverall([
      mosQ,
      rttQ,
      jitterQ,
      lossQ,
    ]);

    return PreflightResultInfo(
      mos: mos,
      rttMs: rtt,
      jitterMs: jitter,
      packetLossPercent: packetLoss,
      mosQuality: mosQ,
      rttQuality: rttQ,
      jitterQuality: jitterQ,
      packetLossQuality: lossQ,
      overallQuality: overall,
      warnings: List<dynamic>.from(rawWarnings),
    );
  }
}

// パケットロス率の抽出(複数のデータ源から取得を試行)
double? _extractPacketLossPercent({
  required Map<String, dynamic>? networkStats,
  required Map<String, dynamic>? report,
}) {
  final fromStatsNum = _extractNumericAverage(networkStats?['packetLoss']);
  final fromStats =
      _normalizePercent(fromStatsNum ?? networkStats?['packetLoss']);
  if (fromStats != null) return fromStats;

  // statsSamplesからも取得を試行
  final samples = (report?['statsSamples'] as List?)
      ?.whereType<Map<String, dynamic>>()
      .toList();
  if (samples == null || samples.isEmpty) return null;

  for (var i = samples.length - 1; i >= 0; i--) {
    final value = _normalizePercent(samples[i]['packetsLostFraction']);
    if (value != null) return value;
  }

  return null;
}

ユーザーへのメッセージング

実際の運用では、複数の品質問題の組み合わせパターンを考慮した、より詳細なメッセージ生成ロジックを実装しています。

PreflightResultInfoMessage.dart
extension PreflightResultInfoMessage on PreflightResultInfo {
  String get detailedMessage {
    bool has(QualityInfo q) => q.hasProblem;

    final hasMos = has(mosQuality);
    final hasRtt = has(rttQuality);
    final hasJitter = has(jitterQuality);
    final hasLoss = has(packetLossQuality);

    final problemCount = [hasRtt, hasJitter, hasLoss].where((e) => e).length;

    if (problemCount == 0 && !hasMos) return 'クリアな音声通話が期待できます';
    if (problemCount == 3) return '通信が著しく不安定で、安定した会話は困難な状態です。';
    if (hasRtt && (hasJitter || hasLoss)) {
      return '会話が遅れがちで、かつ音声も途切れやすいなど、総合的に品質が低下しています。';
    }
    if (hasJitter && hasLoss) {
      return '音声が頻繁に途切れたり乱れたりするなど、通信が非常に不安定です。';
    }
    if (hasRtt) return '会話に遅延(タイムラグ)が発生しやすい状態です。';
    if (hasJitter) return '音声が震えたり、途切れ途切れになったりする原因と考えられます。';
    if (hasLoss) return '音声の一部が欠けて、無音になりやすい状態です。';
    if (hasMos) return '音声品質そのものが総合的に低下しているようです。';

    return '通話品質は良好です';
  }
}

ネイティブ実装

Preflight の実行自体はiOS/Androidネイティブの内製SDK側で行なっており、そちらはTwilio Voice SDK にサンプルがあるので、ネイティブ部分の詳細実装は省略します。

実際の実装方法については、Twilio 公式ドキュメントのサンプルが最もわかりやすいです:

https://www.twilio.com/docs/voice/sdks/mobile-preflight-test#run-a-preflighttest

Android Sample
class MyPreflightObserver implements PreflightTest.Listener {
    @Override
    public void onPreflightConnected(@NonNull final PreflightTest preflightTest) {
        // preflight test has connected
    }

    @Override
    public void onPreflightCompleted(
    @NonNull final PreflightTest preflightTest, @NonNull final JSONObject report) {
        // Check the result in the report
    }

    @Override
    public void onPreflightFailed(
    @NonNull final PreflightTest preflightTest, @NonNull final CallException error) {
        // Check the failure reason in the error
    }

    @Override
    public void onPreflightWarning(
    @NonNull final PreflightTest preflightTest,
    @NonNull final Set<Call.CallQualityWarning> currentWarnings,
    @NonNull final Set<Call.CallQualityWarning> previousWarnings) {
        // Check for any warnings received during test
    }

    @Override
    public void onPreflightSample(
    @NonNull final PreflightTest preflightTest, @NonNull final JSONObject sample) {
        // check each stats sample received from the test
    }
}
iOS sample
class ViewController: UIViewController {
    var preflightTest: PreflightTest? = nil

    func performPreflight() {
        let preflightOptions = PreflightOptions(accessToken: accessToken, block: { builder in
            builder.preferredAudioCodecs = [OpusCodec()]
        })

        preflightTest = TwilioVoiceSDK.runPreflightTest(options: preflightOptions, delegate: self)
    }
}

extension ViewController: PreflightDelegate {
    func preflightDidComplete(preflightTest: PreflightTest, report: PreflightReport) {
        // Check the result in the report
    }

    func preflightDidFail(preflightTest: PreflightTest, error: any Error) {
    // Check the failure reason in the error
    }

    func preflightDidConnect(preflightTest: PreflightTest) {
    // preflight test has connected
    }
}

参考資料

Twilio公式ドキュメント

まとめ

Twilio Preflightを活用した音声品質診断機能により、以下を実現できました。

  • 問題の事前発見:ユーザー自身による品質診断
  • サポート効率化:具体的なデータに基づく問題解決、SalesやCustomer Supportへ問い合わせ時点で問題の切り分け
  • ユーザー体験向上:透明性の高い情報の提供

品質判定ロジックは、Twilioの基準をベースに“ユーザーにとって意味のある情報”になるよう調整しています。
数字を見せるだけでなく、直面する状況を理解できる表現にすることが大切だと感じました。

We are hiring!

IVRyでは「イベントや最新ニュース、募集ポジションの情報を受け取りたい」「会社について詳しく話を聞いてみたい」といった方に向けて、キャリア登録やカジュアル面談の機会をご用意しています。ご興味をお持ちいただけた方は、ぜひ以下のページよりご登録・お申し込みください。
https://ivry-jp.notion.site/209eea80adae800483a9d6b239281f1b

IVRyテックブログ

Discussion