開発ノート

リアルタイム音声認識APIをアプリに組み込む——Sonioxでのストリーミング設計

リアルタイム音声認識APIをアプリに組み込む——Sonioxでのストリーミング設計

「話しているそばから字幕が出る」体験をアプリに足したい。そう思ってリアルタイム音声認識APIを調べ始めると、選択肢は多いのに、実装の勘所を書いた記事は意外と少ない——半年間ひとりでライブ翻訳アプリ「Hiroi(拾い)」をつくってきて、そう感じました。

ここでは、私が実際に使っている Soniox を具体例に、リアルタイム音声認識をアプリへ組み込むときの設計を、アーキテクチャの視点で整理します。Sonioxの使い方そのものというより、「どの真後ろの構造が、現場で動くかどうかを決めるのか」の話です。iOS/SwiftUIの一人開発を前提にしていますが、考え方はプラットフォームを問いません。

なぜ「話し終わってから」では遅いのか

音声認識APIには大きく二種類あります。録音した音声ファイルをまとめて投げて結果を受け取るバッチ(ファイル)認識と、話している最中の音を少しずつ送って途中経過を返してもらうストリーミング音声認識です。

字幕やライブ翻訳を作るなら、選ぶべきはストリーミング一択でした。理由は単純で、バッチは「発話が終わってから」しか結果が出ないから。会議のテンポでは、一文が終わるのを待っていたら、もう次の話題に移っています(この体感差はリアルタイム翻訳で会議はこう変わるにも書きました)。

ストリーミングなら、話者がまだ喋っている最中に、確定前の「途中の文字」が返ってきます。人は、完璧な字幕より「今この瞬間、何か出ている」ことに安心する。テンポが崩れないことのほうが、精度より効くのです。リアルタイム字幕の実装は、この一点から設計が始まります。

WebSocketとPCMフレーム——リアルタイム音声認識APIの土台

ストリーミングの実装は、たいていWebSocketの上に載ります。Hiroiの場合、Sonioxとの間にWebSocketを一本張り、モデルは `stt-rt-v5` を指定します。

流れる音声のフォーマットは、飾りがなく素直です。`pcm_s16le`(16bitリニアPCM)、16kHz、モノラル。これを約120msぶんずつの小さなフレームに切って、WebSocketへ順番に送り続けます。マイクから拾った音を変換して、120msごとにポン、ポンと投げる。ストリームを閉じたいときは、空のフレームを1つ送る——それが「話し終わり」の合図になります。

最初の接続時に、認識する言語・翻訳設定・モデル名などをJSONで一度渡し、あとはひたすら音声フレームを流す。土台はこれだけです。逆に言うと、この単純なループ(音を切る→送る→結果を受け取る)がすべての前提になるので、ここが詰まると全部が崩れます。

中間結果(interim)と確定結果(final)

ストリーミングの肝は、結果が二段階で返ってくることです。

  • 中間結果(interim):まだ確定していない、書きかけの文字。話が進むと上書きされ、消えたり変わったりする。
  • 確定結果(final):もう変わらないと判断された、確定した文字。

UIの作り方も、この二段階に素直に従うのが正解でした。interimは「薄く・仮に」表示して、finalに変わったら本文として定着させる。ユーザーには「文字が生まれて、固まっていく」ように見えます。ここを一緒くたにすると、画面がチラついたり、確定したはずの字幕が揺れて読みにくくなる。中間結果は「まだ揺れていい場所」、確定結果は「もう動かさない場所」——この線引きだけ最初に決めておくと、後がずいぶん楽になります。

音声から直接翻訳して、言語で振り分ける

Sonioxのおもしろいところは、翻訳をテキストではなく音声から直接返せる点です。ふつうの機械翻訳は「音声→文字起こし→その文字を翻訳」の二段構えですが、ここでは音声認識と翻訳がひとつのストリームで返ってきます。

さらに、返ってくるトークン(単語や字のかたまり)には言語のタグが付きます。Hiroiはこれを使って、トークンを正規化した元言語ごとに画面の左右へ振り分けています。原文のトークンはその言語そのもの、翻訳のトークンは「元の言語」を基準に側を決める。日本語・英語・中国語の三言語を、話者ごとに散らからせずに並べられるのは、このper-tokenの言語情報のおかげです。

ここは製品ごとに設計が分かれるところですが、「翻訳が音声から直接来る」「トークンに言語が付く」という二つの性質を知っておくと、字幕のレイアウトを組むときの自由度がぐっと上がります。

本当に効くのは、地味な信頼性の作り込み

ここまでは、どのチュートリアルにも書いてある話です。でも、リアルタイム音声認識が「デモでは動くのに現場で落ちる」かどうかを分けるのは、もっと地味な部分でした。ひとりでアプリを出してみて、いちばん時間を取られたのもここです。

1. 再接続は指数バックオフで。 モバイルの回線は普通に切れます。切れたら即つなぎ直すのではなく、待ち時間を少しずつ延ばしながら(指数バックオフで)再接続する。失敗するたびに間隔を空けるので、電波の悪い場所で無限に叩き続けずに済みます。

2. ソケットに「世代番号」を持たせる。 これは地味だけど効きました。ソケットが切れて張り直したあと、古いソケットの遅れて届いた非同期コールバックが、新しい接続を触ってしまう事故があります。対策は、接続ごとに世代のカウンタを1つ持たせること。コールバックが走るときに「自分の世代は今も最新か?」を確認し、違えば黙って捨てる。概念だけ書けば、各コールバックの先頭に `if generation != current { return }` を置くだけです。これがないと、死んだソケットのゾンビ・コールバックが、生きた接続を壊します。

3. 開始のたびにマイクを固定し直す。 macOSで痛い目を見たのがこれ。認識を(再)スタートするたびに、使う入力デバイスを毎回明示的に指定し直します。途中でBluetoothのイヤホンが繋がると、OSが既定のマイクをそのイヤホンのマイクへ勝手に切り替えるからです。最初に一度だけ固定していると、経路が変わった瞬間に、意図しないマイクへ乗っ取られる。だから「最初だけ」ではなく「毎回」。

4. APIキーはリージョン紐付き。 最後に運用の落とし穴。Sonioxのキーは地域に紐付いています。USのキーはUSのエンドポイントでしか通らない。キーとエンドポイントの地域が食い違うと「Incorrect API key(キーが不正)」に見えるエラーになりますが、実際はキーが悪いのではなくリージョンの不一致、ということが起きます。

そして鍵の渡し方も、この地味な層の一部です。Hiroiは二通りを同じ口で扱えるようにしています。マネージドはこちらのバックエンドがサーバー側で短命の一時トークンを発行し、アプリはそれで繋ぐ(長期キーを端末に置かずに済む)。BYOはユーザー自身のSonioxキーを使う。どちらの場合も原則はひとつ——音声そのものは端末からSonioxへ直接流し、自分のサーバーで中継(プロキシ)はしない。トークンの発行だけをサーバーが担い、重い音声ストリームはデバイス↔Sonioxの直結にする。これでプライバシーもコストもレイテンシも軽くなります。

華やかさゼロですが、ユーザーの手元で毎日動くかどうかは、ほぼこの層で決まります。

リアルタイム音声認識APIの導入は、最初のWebSocketを繋ぐところまでは驚くほど簡単です。差がつくのはその先——中間結果の扱い、世代ガード、マイクの再固定、リージョンの罠。派手さはないけれど、この地味な層こそが「デモ」と「毎日使える道具」を分けます。

(余談ですが、私はいちばん時間をかけた音声通訳機能を、最後に自分で捨てました。その判断は一番時間をかけた機能を、なぜ捨てたかに書いています。)

出来上がったものが気になれば、hiroi.app をのぞいてみてください。あなたのアプリにも、話しているそばから字幕が生まれますように。

他の言語で読むEnglish中文