Twilio Media Streams の処理遅延が膨らむ問題を解決する、遅延ガード実装

Twilio Media Streams の処理遅延が膨らむ問題を解決する、遅延ガード実装

Twilio Media Streams では、下流処理が追いつかないと未処理音声がキューに溜まり、バックログが秒単位で増え続けます。本記事では、遅延予算を超えた音声を間引く遅延ガードを実装し、バックログを数百 ms 程度に抑えられることを実測で確認します。
2025.12.28

はじめに

Twilio Media Streams を使うと、通話中の音声を WebSocket でリアルタイムに受け取れます。一方で、下流処理 (例: 音声解析や AI 推論) が重くなると、キューに未処理の音声が溜まり続け、どんどん過去の音声を処理することになり、遅延が雪だるま式に増えることがあります。結果として、ユーザーが今しゃべった音声をサーバが扱うのが数秒〜数十秒遅れる、という状態が起きます。

image-increasing-latency

本記事では、その問題を解決する一手法として「遅延ガード」を実装し、効果を検証します。遅延ガードとは、ここでは遅延予算 (例: 200 ms) を超えたら古い音声を間引く仕組みのことを指します。既存の概念としては遅延目標を守るための ロードシェディング (間引き) に近いです。この仕組みの導入有無で遅延がどのように変わるか比較することが、本記事の狙いです。

対象読者

  • Media Streams を試したいが、遅延の扱いが不安な方
  • リアルタイム音声処理における遅延の増え方と対策の方向性を掴みたい方

前提

  • 片方向 (Twilio → サーバ) のみ
    • 受け取った音声を Twilio 側へ返す処理はスコープ外
  • 「遅延ガード」は、音声を捨てる ことで遅延を抑える
    • 「遅延を小さくする代わりに情報量が減る」点はトレードオフ

用語

  • バックログ
    未処理の音声が「どれだけ時間として溜まっているか」を ms で表したもの
    バックログ = 最新の受信タイムスタンプ − 処理済みタイムスタンプ
  • 遅延予算
    これ以上遅れると体験が悪化しやすい、という上限目安
  • 遅延ガード
    遅延予算を超えそうなら、古い音声を間引いて最新に追従する仕組み

参考

構成

  1. Twilio から WebSocket で音声イベントを受ける
  2. 受信した media をキューに積む
  3. ワーカーがキューから取り出して処理する (今回は固定 sleep)
  4. 遅延ガードが有効なら、バックログが閾値を超えた時点で古いものを捨てる

実装

Twilio コンソールでの操作

Twilio Functions に、Incoming Webhook 用の Function を作成します。

/media-stream-incoming Function
exports.handler = function (context, event, callback) {
  const WSS_URL = context.WSS_URL; // ここだけ Twilio 側の環境変数

  const twiml = new Twilio.twiml.VoiceResponse();

  // Twilio → WebSocket サーバへストリーム開始
  twiml.start().stream({
    url: WSS_URL,
    track: 'inbound_track',
  });

  // 通話がすぐ終わらないように最低限の応答 (検証のため) 
  twiml.say('Media stream started. Please speak.');
  twiml.pause({ length: 60 });

  callback(null, twiml);
};

環境変数を以下のように設定します。

変数名 意味
WSS_URL wss://****.com/twilio 後述する手順で取得する接続先

Twilio 購入番号の Voice Configuration の Incoming Webhook に上記 Function の URL を入力し、リクエストタイプとして POST を設定します。

set function url

WebSocket サーバ構築

今回は「外に公開できて、WSS で受けられれば OK」なので Render.com を使いました。

Render 側の環境変数は以下の通りです。

変数名 意味
SLEEP_MS 60 擬似処理時間 (ms)
L1_MS 200 遅延予算 (ms)
ENABLE_GOVERNOR 0 / 1 遅延ガードの無効/有効

検証用の server.js は以下の通りです。

server.js
const http = require('http');
const express = require('express');
const { WebSocketServer } = require('ws');

const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.status(200).send('ok');
});

const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/twilio' });

// Render 側で制御する環境変数
const SLEEP_MS = Number(process.env.SLEEP_MS || 60);
const ENABLE_GOVERNOR = String(process.env.ENABLE_GOVERNOR || '0') === '1';
const L1_MS = Number(process.env.L1_MS || 200);

// monotonic な経過時間 (ms)
const t0 = process.hrtime.bigint();
function nowMs() {
  return Number((process.hrtime.bigint() - t0) / 1000000n);
}
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

wss.on('connection', (ws, req) => {
  console.log('[ws] connected', { path: req.url });

  const state = {
    streamSid: null,
    mediaFormat: null,

    tsLatest: null,
    tsDone: null,

    lastSeq: null,
    droppedChunks: 0,

    queue: [], // { ts, seq }[]
    closed: false,
  };

  function backlogMs() {
    if (state.tsLatest == null || state.tsDone == null) return null;
    return state.tsLatest - state.tsDone;
  }

  // 遅延ガード: 「最新から L1_MS より古いもの」を捨てる (drop-head) 
  function applyGovernor() {
    if (!ENABLE_GOVERNOR) return;
    if (state.tsLatest == null) return;

    const cutoff = state.tsLatest - L1_MS;

    while (state.queue.length > 0) {
      const head = state.queue[0];
      if (head.ts >= cutoff) break;
      state.queue.shift();
      state.droppedChunks += 1;
    }
  }

  async function workerLoop() {
    for (;;) {
      if (state.closed) return;

      if (state.queue.length === 0) {
        await sleep(5);
        continue;
      }

      const item = state.queue.shift();

      // 擬似下流 (固定 sleep) 
      await sleep(SLEEP_MS);

      state.tsDone = item.ts;

      // 処理のたびに「追いつけてるか」を確認
      applyGovernor();
    }
  }

  // 1 秒に 1 行だけ CSV を出す (ログ肥大化を防ぐ) 
  const metricTimer = setInterval(() => {
    const b = backlogMs();
    if (b == null) return;

    // metric,t_wall_ms,ts_latest,ts_done,backlog_ms,queue_len,dropped_chunks,governor,sleep_ms,l1_ms
    console.log(
      [
        'metric',
        nowMs(),
        state.tsLatest,
        state.tsDone,
        b,
        state.queue.length,
        state.droppedChunks,
        ENABLE_GOVERNOR ? 1 : 0,
        SLEEP_MS,
        L1_MS,
      ].join(',')
    );
  }, 1000);

  workerLoop().catch((e) => {
    console.log('[worker] error', { message: e?.message });
  });

  ws.on('message', (message) => {
    const text = Buffer.isBuffer(message) ? message.toString('utf8') : String(message);

    let data;
    try {
      data = JSON.parse(text);
    } catch (e) {
      return;
    }

    const ev = data.event;

    if (ev === 'connected') {
      console.log('[twilio] connected');
      return;
    }

    if (ev === 'start') {
      state.streamSid = data.start?.streamSid ?? null;
      state.mediaFormat = data.start?.mediaFormat ?? null;

      state.tsLatest = null;
      state.tsDone = null;
      state.lastSeq = null;
      state.droppedChunks = 0;
      state.queue.length = 0;

      console.log('[twilio] start', {
        streamSid: state.streamSid,
        mediaFormat: state.mediaFormat,
        governor: ENABLE_GOVERNOR ? 1 : 0,
        sleepMs: SLEEP_MS,
        l1Ms: L1_MS,
      });
      return;
    }

    if (ev === 'media') {
      const ts = Number(data.media?.timestamp);
      const seq = Number(data.sequenceNumber);
      if (!Number.isFinite(ts) || !Number.isFinite(seq)) return;

      if (state.lastSeq != null && seq !== state.lastSeq + 1) {
        console.log('[warn] seq gap', { prev: state.lastSeq, current: seq, delta: seq - state.lastSeq });
      }
      state.lastSeq = seq;

      state.tsLatest = ts;
      state.queue.push({ ts, seq });

      // 受信時にも追いつき判定
      applyGovernor();
      return;
    }

    if (ev === 'stop') {
      console.log('[twilio] stop', { streamSid: state.streamSid ?? data.streamSid });
      return;
    }
  });

  ws.on('close', () => {
    state.closed = true;
    clearInterval(metricTimer);
    console.log('[ws] closed');
  });

  ws.on('error', (err) => {
    console.log('[ws] error', { message: err?.message });
  });
});

server.listen(port, '0.0.0.0', () => {
  console.log('[http] listening', { port: String(port) });
});

これを次の手順でデプロイします。

  • GitHub に push
  • Render.com で Web Service を作成してリポジトリを接続
  • Start Command を node server.js にする
  • Environment Variables に SLEEP_MS, L1_MS, ENABLE_GOVERNOR を設定
  • デプロイ後、https://<service>.onrender.com/ok を返せば起動確認 OK

Render.com settings

検証

同じ条件で、 Twilio 番号に対し 60 秒程度の電話をかけて音声通話を行い、次の 2 パターンを比べます。

  • パターン A: 遅延ガードなし (ENABLE_GOVERNOR=0)
  • パターン B: 遅延ガードあり (ENABLE_GOVERNOR=1, L1_MS=200)

擬似処理は SLEEP_MS=60 の固定にします。これは「処理が追いつかない状況」を確実に作るためです。

Render.com では、ログのモードを Live tail とし、ログを取得しました。

render logs

結果

今回のログ (各 63 サンプル、約 62 秒) を集計すると次のようになりました。

条件 バックログ最大 バックログ中央値 バックログ 95 パーセンタイル キュー最大 間引き総数
遅延ガードなし 42020 ms 21380 ms 39954 ms 2100 0
遅延ガードあり 320 ms 300 ms 320 ms 11 2089
  • パターン A: 遅延ガードなし
    バックログが最大で約 42 秒まで膨らみました。「今しゃべった内容」をサーバが扱うのが数十秒遅れ得る状態です。

  • パターン B: 遅延ガードあり
    バックログは最大でも 320 ms 程度に抑えられました。その代わり、処理が追いつかない分は 間引き が発生しています。

バックログ推移

backlog_compare_en

キュー長推移

queue_len_compare_en

遅延ガード有効時における間引き累積

dropped_chunks_en

パターン A 詳細データ
metric,t_wall_ms,ts_latest,ts_done,backlog_ms,queue_len,dropped_chunks,governor,sleep_ms,l1_ms
metric,127053,1227,467,760,37,0,0,60,200
metric,128053,2227,807,1420,70,0,0,60,200
metric,129053,3207,1127,2080,103,0,0,60,200
metric,130053,4207,1467,2740,136,0,0,60,200
metric,131053,5207,1807,3400,169,0,0,60,200
metric,132053,6207,2127,4080,203,0,0,60,200
metric,133053,7207,2467,4740,236,0,0,60,200
metric,134053,8207,2787,5420,270,0,0,60,200
metric,135053,9207,3127,6080,303,0,0,60,200
metric,136053,10207,3467,6740,336,0,0,60,200
metric,137053,11207,3787,7420,370,0,0,60,200
metric,138053,12207,4127,8080,403,0,0,60,200
metric,139053,13207,4467,8740,436,0,0,60,200
metric,140053,14207,4787,9420,470,0,0,60,200
metric,141053,15207,5127,10080,503,0,0,60,200
metric,142053,16207,5467,10740,536,0,0,60,200
metric,143053,17207,5787,11420,570,0,0,60,200
metric,144053,18207,6127,12080,603,0,0,60,200
metric,145053,19207,6467,12740,636,0,0,60,200
metric,146053,20207,6787,13420,670,0,0,60,200
metric,147053,21207,7127,14080,703,0,0,60,200
metric,148053,22207,7467,14740,736,0,0,60,200
metric,149053,23207,7787,15420,770,0,0,60,200
metric,150053,24207,8127,16080,803,0,0,60,200
metric,151053,25207,8447,16760,837,0,0,60,200
metric,152053,26207,8787,17420,870,0,0,60,200
metric,153054,27207,9127,18080,903,0,0,60,200
metric,154054,28207,9447,18760,937,0,0,60,200
metric,155054,29207,9787,19420,970,0,0,60,200
metric,156054,30207,10127,20080,1003,0,0,60,200
metric,157054,31207,10447,20760,1037,0,0,60,200
metric,158054,32167,10787,21380,1068,0,0,60,200
metric,159054,33167,11127,22040,1101,0,0,60,200
metric,160054,34167,11447,22720,1135,0,0,60,200
metric,161054,35167,11787,23380,1168,0,0,60,200
metric,162054,36167,12127,24040,1201,0,0,60,200
metric,163054,37167,12447,24720,1235,0,0,60,200
metric,164054,38167,12787,25380,1268,0,0,60,200
metric,165054,39167,13107,26060,1302,0,0,60,200
metric,166054,40167,13447,26720,1335,0,0,60,200
metric,167054,41167,13787,27380,1368,0,0,60,200
metric,168055,42187,14107,28080,1403,0,0,60,200
metric,169055,43187,14447,28740,1436,0,0,60,200
metric,170056,44167,14787,29380,1468,0,0,60,200
metric,171057,45187,15107,30080,1503,0,0,60,200
metric,172057,46147,15447,30700,1534,0,0,60,200
metric,173057,47147,15787,31360,1567,0,0,60,200
metric,174057,48147,16107,32040,1601,0,0,60,200
metric,175058,49147,16447,32700,1634,0,0,60,200
metric,176058,50147,16787,33360,1667,0,0,60,200
metric,177058,51127,17107,34020,1700,0,0,60,200
metric,178058,52127,17447,34680,1733,0,0,60,200
metric,179062,53127,17787,35340,1766,0,0,60,200
metric,180063,54127,18107,36020,1800,0,0,60,200
metric,181063,55127,18447,36680,1833,0,0,60,200
metric,182063,56127,18767,37360,1867,0,0,60,200
metric,183064,57127,19107,38020,1900,0,0,60,200
metric,184064,58127,19447,38680,1933,0,0,60,200
metric,185065,59127,19767,39360,1967,0,0,60,200
metric,186065,60127,20107,40020,2000,0,0,60,200
metric,187065,61127,20447,40680,2033,0,0,60,200
metric,188065,62127,20767,41360,2067,0,0,60,200
metric,189065,63127,21107,42020,2100,0,0,60,200
パターン B 詳細データ
metric,t_wall_ms,ts_latest,ts_done,backlog_ms,queue_len,dropped_chunks,governor,sleep_ms,l1_ms
metric,24066,1187,887,300,11,29,1,60,200
metric,25065,2187,1907,280,11,62,1,60,200
metric,26065,3187,2867,320,11,96,1,60,200
metric,27065,4167,3867,300,11,128,1,60,200
metric,28066,5167,4887,280,11,161,1,60,200
metric,29066,6167,5847,320,11,195,1,60,200
metric,30066,7167,6867,300,11,228,1,60,200
metric,31066,8167,7887,280,11,261,1,60,200
metric,32067,9167,8847,320,11,295,1,60,200
metric,33066,10167,9867,300,11,328,1,60,200
metric,34066,11167,10887,280,11,361,1,60,200
metric,35066,12167,11847,320,11,395,1,60,200
metric,36066,13167,12867,300,11,428,1,60,200
metric,37066,14147,13867,280,11,460,1,60,200
metric,38066,15147,14827,320,11,494,1,60,200
metric,39066,16147,15867,280,11,527,1,60,200
metric,40067,17147,16887,260,10,561,1,60,200
metric,41066,18147,17847,300,11,594,1,60,200
metric,42066,19147,18867,280,11,627,1,60,200
metric,43066,20147,19887,260,10,661,1,60,200
metric,44066,21147,20847,300,11,694,1,60,200
metric,45067,22147,21847,300,11,727,1,60,200
metric,46066,23147,22887,260,10,761,1,60,200
metric,47066,24147,23827,320,11,794,1,60,200
metric,48066,25107,24827,280,11,825,1,60,200
metric,49067,26087,25827,260,10,858,1,60,200
metric,50066,27087,26787,300,11,891,1,60,200
metric,51066,28087,27807,280,11,924,1,60,200
metric,52066,29087,28767,320,11,958,1,60,200
metric,53066,30087,29787,300,11,991,1,60,200
metric,54067,31087,30807,280,11,1024,1,60,200
metric,55067,32087,31767,320,11,1058,1,60,200
metric,56067,33087,32787,300,11,1091,1,60,200
metric,57067,34067,33787,280,11,1123,1,60,200
metric,58067,35067,34747,320,11,1157,1,60,200
metric,59067,36067,35767,300,11,1190,1,60,200
metric,60067,37067,36787,280,11,1223,1,60,200
metric,61067,38067,37747,320,11,1257,1,60,200
metric,62068,39067,38767,300,11,1290,1,60,200
metric,63067,40067,39787,280,11,1323,1,60,200
metric,64067,41067,40747,320,11,1357,1,60,200
metric,65067,42067,41787,280,11,1390,1,60,200
metric,66067,43067,42807,260,10,1424,1,60,200
metric,67067,44067,43767,300,11,1457,1,60,200
metric,68067,45067,44787,280,11,1490,1,60,200
metric,69068,46067,45807,260,10,1524,1,60,200
metric,70067,47067,46767,300,11,1557,1,60,200
metric,71067,48067,47787,280,11,1590,1,60,200
metric,72067,49067,48807,260,10,1624,1,60,200
metric,73067,50067,49767,300,11,1657,1,60,200
metric,74067,51067,50787,280,11,1690,1,60,200
metric,75067,52067,51747,320,11,1724,1,60,200
metric,76068,53067,52767,300,11,1757,1,60,200
metric,77067,54027,53747,280,11,1788,1,60,200
metric,78067,55027,54707,320,11,1822,1,60,200
metric,79067,56027,55727,300,11,1855,1,60,200
metric,80067,57027,56747,280,11,1888,1,60,200
metric,81066,58027,57707,320,11,1922,1,60,200
metric,82068,59027,58727,300,11,1955,1,60,200
metric,83068,60027,59747,280,11,1988,1,60,200
metric,84067,61027,60727,300,11,2022,1,60,200
metric,85067,62027,61747,280,11,2055,1,60,200
metric,86068,63027,62767,260,10,2089,1,60,200

考察

情報欠落とのトレードオフである

遅延ガードありの dropped_chunks=2089 は、概算で 約 41.78 秒分 の音声フレームを捨てた計算になります。この実験では SLEEP_MS=60 により、到着より処理が遅い状態を意図的に作っているため、間引きが大きいこと自体は自然です。

重要なのは、 遅延ガードが入ることでシステムの性質が変わる 点です。遅延ガードなしでは、処理が追いつかない限りバックログは原理的に増え続けます。一方で遅延ガードありでは、バックログを遅延予算付近に張り付かせる代わりに、処理しきれない分を欠落として外に出します。つまり遅延ガードは、過負荷状態を「無限に遅延する系」から「遅延を有限に保つ代わりに欠落する系」へ変換する設計です。

この設計が向くのは、キーワード検知やライブ字幕の追従性重視など、完全性よりも即時性を優先するユースケースです。一方で、監査目的の保存や後段で厳密な文字起こしを行う用途のように、欠落が許されないユースケースには不向き です。欠落が許されない場合は、遅延ガードではなく処理能力の増強、処理の軽量化、非同期化、保存系とリアルタイム系の分離などを優先すべきです。

破棄ロジックの工夫

今回の実装は、 バックログが遅延予算を超えそうな場合に「最新から見て古いものを先頭から捨てる」という単純な drop-head です。 この方式は実装が簡単で、遅延を確実に抑えられます。一方で、 捨て方を工夫すれば、遅延予算を守りつつ情報価値を少しでも残せる可能性があります。

たとえば、無音や低エネルギー区間を優先的に捨てる方針が考えられます。人間の会話や字幕生成では、無音区間の欠落は意味理解への影響が比較的小さいことが多いためです。単純な drop-head よりも、発話密度の高い部分を残しやすくなります。

また、間引きを連続破棄ではなく、時間的なサンプリングに寄せる方法もあります。例えば過負荷時は「一定間隔で 1 チャンクだけ通す」ようにして、粗い時間解像度で追従します。完全性は落ちますが、内容の概略や話題の変化を追う用途では、連続破棄より有利になることがあります。今回の条件であれば、処理が 1/3 しか追いつかないので、過負荷時は 3 チャンクに 1 チャンクだけ処理対象にする、といった設計が自然です。drop-head は結果として同程度の欠落率になりますが、破棄が連続になりやすいため、用途によってはサンプリング型の方が品質が安定します。

better methods

このように、遅延ガードは単に古いものを捨てる仕組みではなく、過負荷時の欠落をどのように設計するか、という品質設計の問題でもあります。対象ユースケースを 1 つ選び、欠落の仕方を変えた場合に下流の品質指標 (例: キーワード検知率、字幕の追従遅延、文字起こしの誤り率など) がどう変わるかを評価すると、遅延ガードの適用判断をより定量化できます。

まとめ

Twilio Media Streams では、WebSocket で受信した音声の下流処理が到着レートに追いつかないと、バックログが増え続け、音声の取り扱いが数十秒遅れる状態になり得ます。遅延ガードを導入すると、遅延予算 (例: 200 ms) 付近に遅延を抑えられますが、処理しきれない分はロードシェディングとして欠落し、完全性とのトレードオフになります。実運用では、無音優先の破棄やサンプリング型の間引きなどユースケースに合わせて破棄ロジックを選び、下流の品質指標とセットで評価すると適用判断がしやすくなります。

この記事をシェアする

FacebookHatena blogX

関連記事