本文へ移動
BenriWorks Lab

開発記事公開日

音声認識ストリームの時間制限を、利用者に見せずに乗り越える

明るい会議室で、ヘッドセットを着けてノートPCの文字起こし画面に向かう女性エンジニア

対話支援アプリLiveBriefで、クラウド音声認識のストリーム上限や無音切断を透過的なローテーションで吸収した実装を紹介します

ストリーミング音声認識は、一度ストリームを開けばこちらが話し続けるだけ、という顔をしています。マイクの音声を流し込み、認識結果を受け取る。サンプルコードならそれで完結します。ところが、1時間の商談で使うつもりで組み込むと、この素朴な期待は外部APIの三つの都合に破られました。1ストリームの継続時間に上限があること。音声を送らない時間が続くとストリームが強制切断されること。そして、設定に未対応の機能が混ざると認識そのものが始まらないことです。対話支援アプリLiveBriefでは、Google CloudのSpeech-to-Text V2をこの条件で使うために、三つの都合をすべてサーバ側で吸収する作りにしました。この記事では、その切り替えの実装と、実測で利用者からどこまで隠せたかを紹介します。

WebSocketとgRPCのあいだに立つリレー

防御をどこに置くかは、構成の制約が先に決めてくれました。Speech-to-Text V2のストリーミング認識はgRPCのAPIで、ブラウザからは直接つなげません。そこでブラウザとSTTのあいだに、Cloud Run上の小さなリレーを置いています。スマホのブラウザからWebSocketで16kHzのPCM音声を受け取り、gRPCでSTTへ中継し、認識結果をJSONのイベントで返すサーバです。音声はこのリレーのメモリを通過するだけで、保存しません。

この一枚が挟まっているおかげで、STT側のストリームの寿命と、利用者から見た接続の寿命を切り離せます。ブラウザとの接続は1セッションに1本のWebSocketのままで、その裏でSTTのストリームを何度でも作り直せます。確定した発話に振る連番(seq)もリレーがセッション通しで採番するので、ストリームが替わっても番号は続きます。ネットワーク断でWebSocketそのものが切れた場合は、ブラウザ側が受信済みの最終seqを添えて再接続し、リレーはその続きから採番を再開します。受信側でも既知の番号以下の確定結果は捨てるため、再送で同じ発話が二重に並ぶことはありません。以降の防御は、すべてこのリレーの上に実装しました。

音声認識リレーの構成図。ブラウザが16kHzのPCM音声をWebSocketでCloud Run上のリレーへ送り、リレーがgRPCでSpeech-to-Text V2へ中継し、認識結果をJSONイベントで返す。音声はリレーのメモリを通過するだけで保存しない
この一枚で、STT側のストリームの寿命と、利用者から見た接続の寿命を切り離せる

継続時間の上限の手前で行うローテーション

三つの都合のうち、設計の段階から見えていたのは継続時間の上限です。ストリーミング認識の1ストリームは約5分までとされており、1時間のセッションなら作り直しが十回以上必要になります。もっとも、この上限だけは、実際に踏んだときの挙動を確認していません。踏む前に切り替えるからです。

リレーはストリームを開くたびに270秒(既定値)のタイマーを仕掛け、時間が来たらローテーションを始めます。手順は三段です。まず現在のストリームを送信対象から切り離し、以降に届く音声チャンクはメモリのバッファに積む。次に旧ストリームへの入力を終え、処理中だった確定結果が出切るのを待つ。最後に新しいストリームを開き、バッファの音声をまとめて流し込む。どの瞬間の音声も旧ストリームかバッファか新ストリームのどれかが受け取っているので、切り替えをまたぐ発話も取りこぼしません。

テキストより壊れやすいのは、時刻のほうでした。STTが返す発話の時刻はストリーム先頭からの相対値なので、作り直すたびにゼロへ戻ります。リレーは送信済みの音声バイト数から各ストリームの開始オフセットを計算し(16kHzの16bitモノラルなので1msあたり32バイト)、そのオフセットをストリームごとのクロージャに固定して結果ハンドラへ渡します。

// このストリーム上の時刻 = offsetMs + resultEndOffset。
// ストリームを切り離した後に遅れて届くfinalも正しい時刻になるようクロージャで固定する。
const offsetMs = this.globalOffsetMs;
const stream = this.client._streamingRecognize();
stream.on("data", (resp: unknown) => this.handleResponse(resp, offsetMs));

クロージャにしたのは、旧ストリームの確定結果が切り離しの後に遅れて届くからです。切り離した時点でオフセットの合計は次のストリームのぶんへ進んでいますが、遅れて届いた結果は自分のストリームに固定されたオフセットで時刻に載るため、セッション全体の時間軸は乱れません。

ストリームのローテーションを時間軸で示した図。270秒のタイマーで旧ストリームを送信対象から切り離し、以降の音声をバッファに積み、確定結果が出切るのを待って新ストリームへまとめて流し込む
どの瞬間の音声も、旧ストリームかバッファか新ストリームのどれかが受け取っている

一時停止で踏んだ無音の強制切断

二つめの都合は、検証の初日に踏みました。初回の通し確認でセッションを一時停止したところ、しばらくしてリレーが ABORTED: Stream timed out というエラーで切断されたのです。音声データを送らない状態が10秒ほど続くと、STTはストリームを強制的に打ち切ります(このアプリで確認した挙動です)。マイクを開いたまま黙っているぶんには無音のPCMが流れ続けるので引っかからず、引っかかるのは一時停止のように送信そのものを止める操作でした。一時停止は記録したくない話題の前で使う想定の操作なので、押すたびにエラーが出るのでは困ります。

切られてから開き直す手も考えました。結果は同じに見えるはずです。ただ、それだと正常な操作のたびにエラー経路を通り、ログに異常が積もります。そこで逆にして、切られる前に自分から閉じることにしました。リレーは音声チャンクが届くたびにアイドルタイマーを張り直し、8秒(既定値)音声が来なければ、エラーもイベントも出さずに静かにストリームを閉じます。再開側の実装は、書き込み口の1行だけです。

write(chunk: Buffer): void {
  if (this.closed) return;
  if (this.rotating) {
    this.pendingChunks.push(chunk);
    return;
  }
  if (!this.stream) this.openStream(); // 初回または無音明けの再開
  this.writeToStream(chunk);
  this.resetIdleTimer();
}

ストリームがなければ開く。この1行が初回の接続と無音明けの再開を兼ねるので、再開のための特別な状態遷移はありません。この方式にすると、ストリームは「セッション中ずっと開いているもの」から「音声が流れている間だけ開いているもの」に変わります。開き直すたびに270秒のローテーションタイマーも新しくなるため、一時停止をはさむセッションでは上限側のローテーション自体が起きにくくなります。

設定が原因のINVALID_ARGUMENTと自動デグレード

三つめの都合は、機能を足そうとして踏みました。会話の話者を区別する話者分離を設定してストリームを開くと、認識結果がひとつも返らないまま INVALID_ARGUMENT で失敗します。採用しているモデル(latest_long)と日本語の組み合わせでは話者分離が未対応で、未対応の設定は「その項目だけ無視される」のではなく、ストリームごと失敗させることがわかりました。設定をひとつ足しただけで、本体の文字起こしが一文字も出なくなるわけです。

リレー側の対処は、原因になりうる設定を段階的に外していく自動デグレードです。ストリームのエラーがINVALID_ARGUMENT系だった場合、まず話者分離を無効化して継続を試み、それでも失敗するなら用語集の適用(model adaptation)を外します。どちらの場合も致命的でないエラーとして利用者に一言通知し、文字起こしは次の音声チャンクで再開します。

if (/INVALID_ARGUMENT|unsupported/i.test(err.message)) {
  if (this.diarizationEnabled) {
    this.diarizationEnabled = false;
    this.cb.onError(
      false,
      "この言語・モデルの組み合わせでは話者分離が未対応のため、話者分離を無効化して文字起こしを継続します。"
    );
    return;
  }
  // 同様に、用語集適用(model adaptation)も次の候補として無効化する
}

機能が使えないこと自体は、諦めるしかありません。話者分離はMVPでは見送り、発話テキストからの宛先判定で代替する判断になりました。譲れなかったのは、使えない機能の巻き添えで本体の文字起こしまで止まらないことのほうです。

ヘッドセットを着けた女性エンジニアが手を広げて話しているイラスト。左に「5分の壁をまたいで続ける ストリームを透過的に入れ替える」というキャッチコピーが入っている
上限を踏む前に入れ替える

453秒の連続セッションで測った継続性

防御がそろったところで、Phase 0検証記録として計測を残しました。453秒(約7分半)の連続セッションで、途中のローテーションは1回、seqの欠落は0件。発話の終わりから確定結果が届くまでの遅延はp50で649ms、p95で764msでした(いずれもこの1セッションの実測です)。ローテーションをまたいだ発話が正しい番号と時刻で並ぶことは、この計測で確かめられました。

では、切り替えは利用者にまったく見えないのか。正直に言えば、少しだけ見えます。リレーはローテーションの開始と完了をイベントとして通知しており、画面の状態表示には旧ストリームの結果を出し切るまでのあいだ、小さな再接続バッジが出ます。隠せたのはバッジではなく、切り替えに伴う発話の欠落や重複、時刻の乱れ、つまり利用者が何かしなければならない事態のほうです。文字起こしの列は途切れず、操作は何も要りません。

検証はまだ序盤でもあります。60分の連続セッションは未実施で、1時間ぶんのローテーションを重ねた実績はまだありません。画面ロックや着信をまたぐ実機での検証もこれからです。

それでも、「ストリームを開けば話し続けられる」という冒頭の期待は、リレーという一枚を挟むことで、利用者の側からはほぼ本当になりました。上限が来る前に切り替え、切られる前に閉じ、通らない設定は外して進む。どれも一つひとつは地味な処理ですが、外部APIを長時間の用途へ組み込むときの作り込みどころは、たいていこの地味な一枚に集まります。この実装を載せた「LiveBrief」は開発者本人の個人利用を想定したアプリで、一般向けの提供は行いません(アプリページは紹介のみです)。

関連アプリ

こんな記事も読まれています