音声認識や音声翻訳のアプリを作っていると、避けて通れない問題があります。
スピーカーから出した音を、マイクがもう一度拾ってしまう問題です。
特に、翻訳結果を音声で読み上げながら次の発話を受け付けたい場合、この問題はかなり厄介です。
今回、Mac向けのリアルタイム音声翻訳アプリ「FFTrans HUD 2.3.0」に、LocalVQEを使ったダブルトークキャンセルを実装しました。
LocalVQE自体は軽量なニューラルネットワークモデルですが、実際に組み込んでみると、難しかったのはモデルを動かすことではありませんでした。
「スピーカーから、いつ音が出たのか」をマイク側と正確に合わせること。
今回は、この部分の実装について少し紹介します。
ダブルトークキャンセルとは
通常のAECでは、
スピーカー → マイク
というエコー経路を推定し、マイク入力から再生音を除去します。
これは非常に強力で、FFTrans HUDでも従来からハードウェアAECを利用していました。
実際、Macのスピーカーから翻訳音声を再生しても、その音声が再び文字起こしされることはほぼありません。
しかし問題があります。
再生中に人間が話しづらい。
そこで今回は、再生音が存在している状態でも人間の発話を取り出すことを目的に、LocalVQEによるダブルトークキャンセルを実装しました。
つまり、
スピーカー:翻訳音声を再生中
マイク:
├─ 翻訳音声
└─ 人間の発話
↓
LocalVQE
↓
人間の発話
という状態を目指します。
これによって、翻訳音声を聞きながら次の人が話す「被せ発話」が可能になるからです。
実装の基本構成
今回の処理では、音声を大きく2つに分けています。
- near-end:マイクから入ってくる音
- far-end:TTSなど、スピーカーから再生する音
LocalVQEには、この2つの信号を時間的に対応させて渡します。
処理フォーマットは、
- 16 kHz
- モノラル
- 20 msフレーム
- 320 samples / frame
です。
マイク側については、AudioCaptureManagerから受け取ったネイティブフォーマットを16 kHzモノラルへ変換し、20 msずつ処理します。
概念的には、
AudioCapture
↓
Resample
↓
16kHz / mono
↓
20ms frame
↓
LocalVQE
↓
clean speech
という流れです。
実は難しかったのはfar-end側
LocalVQEにマイク音声を渡すだけなら、それほど難しくありません。
問題は、**「いまマイクに入っている音に対応するスピーカー音は、どの音なのか?」**です。
TTSの再生処理では、音声バッファをオーディオデバイスへスケジュールします。
しかし、
バッファをスケジュールした時刻
と、
実際にスピーカーから音が出始めた時刻
は同じではありません。
そこで、再生側のバッファを時刻情報付きで保持するリングバッファを用意しました。
TTSの再生予定バッファを16 kHzへ変換し、再生予定時刻とともにfar-end参照信号として保存します。
イメージとしては、
TTS buffer
↓
16kHz mono
↓
timestamp付き
↓
far-end ring buffer
[---- speech A ----][---- speech B ----]
↑
時間情報
です。
そしてマイク側の20 msフレームについて、
この音がマイクに入った時刻
を基準にして、その時刻に対応するfar-endの320 samplesを取り出します。
「再生予定時刻」と「実際の再生時刻」はズレる
ここが今回、一番重要だったところです。
Bluetoothなどを含む実際のオーディオデバイスでは、再生開始にはレイテンシがあります。
最初は、再生予定時刻を使ってfar-endを配置してみました。
しかし、実際にオーディオデバイスから再生が始まった時点で、
予定した開始時刻
↓
実際の開始時刻
の差が生じます。
この差分を利用して、その発話のfar-end参照信号全体を補正します。
FFTrans HUDでは、発話開始時に実際のレンダリング時刻を取得し、その発話について一度だけ補正する方式を取ることにしました。
なぜ「常時補正」しなかったのか
ここは実装してみるまで分からなかった部分です。
理屈だけ考えると、
常にマイク入力とfar-endのクロス相関を計算して、最適な位置へ動的に合わせればいい
ように思えます。
しかし、実際にはこれでは実働差は不安定でした。
再生中に毎フレーム位置を動かしてしまうと、参照信号そのものが揺れてしまいます。
そこで、発話開始時に実測したレイテンシで、その発話の参照信号を一度だけ補正する方式にしました。
ソース側でも、発話単位のgenerationを持たせ、そのgenerationに属するチャンクだけをまとめて時間シフトしています。
再生中に何度も補正するとフレームごとに整列が揺れて不安定になるため、1回だけ補正する設計です。
マイク側にもレイテンシがある
さらに、マイク側も完全にリアルタイムではありません。
音響伝搬やAudio I/Oのバッファリングなどによって、マイク入力にも遅延があります。
そこでマイク側にも残留レイテンシを持たせています。
現在の実装では、
var estimatedPlaybackLatency: TimeInterval = 0.01
という値を持たせています。
ただし、これはBluetoothの再生遅延を丸ごと表しているわけではありません。
far-endについては発話開始時の実測値で補正するため、ここに残るのは主にマイク側の遅延です。
バージインはもっと単純にした
もう一つ重要なのが、翻訳音声の再生中にユーザーが話し始めたときの処理です。
ここでは、過剰に複雑な音声認識をするのではなく、入力音量が一定時間継続して閾値を超えたかを見ています。
現在の設定は例えば、
static let bargeInFrames = 12
static let bargeInRMS: Float = 0.012
です。
20 msフレームを前提にすると、12フレームは約240 msです。
つまり、
一瞬だけ音が入った
程度では反応させず、
ある程度の時間、人間が話している
と判断できたところでバージインとして扱います。
これによって、翻訳音声の再生中に発生する瞬間的なノイズなどで、読み上げを不用意に止めることを避けています。
ここはモデルを複雑化するより、時間方向の条件をシンプルにするほうが実用上扱いやすい部分でした。
Bluetoothが入ると一気に難しくなる
また、この処理を実装していてかなり悩まされたのがBluetoothです。
Bluetoothオーディオでは、
- 再生開始時のレイテンシ
- デバイスごとの遅延差
- A2DP / HSPの切り替え
- 接続時の入力・出力デバイス変更
などが発生します。
LocalVQEはnear-endとfar-endの時間関係が重要なので、音声認識中に入出力デバイスが変わると、それまで成立していた同期条件が変わってしまいます。
そのためFFTrans HUDでは、現在は音声認識中に入出力デバイスが変更された場合、処理を停止してユーザーに通知する方式にしました。
無理にホットスワップして復旧するより、リアルタイム音声処理ではこちらのほうが安全だと判断したからです。
まとめ
今回LocalVQEをFFTrans HUDへ組み込んでみて分かったのは、ニューラルネットワークを動かすことより、音声の「時間」を扱うことのほうが難しいということでした。
最終的な構成はそれほど複雑ではありません。
TTS / far-end
│
予定バッファ
│
timestamp付与
│
far-end ring buffer
│
実再生開始時に
1回だけ補正
│
▼
Mic ──→ 16kHz / 20ms ──→ LocalVQE
│
▼
clean speech
重要なのは、near-endとfar-endを同じ時間軸に乗せること。
そして、
再生中に無理に追従し続けず、発話単位で実測して補正すること。
でした。
この仕組みによって、FFTrans HUDでは、
翻訳音声を聞きながら、次の発話をそのまま話す
という使い方が可能になりました。
技術的にはいわゆるダブルトークキャンセルですが、製品として見ると、これは単なるAECの強化ではありません。
「翻訳音声が終わるまで待つ」から、「翻訳音声を聞きながら会話する」へ。
対面翻訳においては、この違いがかなり大きいと感じています。
実装上のポイント
最後に今回の要点をまとめると、
- 16 kHz / mono / 20 msフレームで処理
- near-endとfar-endを別々に管理
- TTS再生予定バッファを時刻付きで保持
- 実際のレンダリング開始時刻を取得
- 発話単位で一度だけレイテンシ補正
- far-endをリングバッファとして保持
- マイクフレームの時刻に対応するfar-endを取り出してLocalVQEへ入力
- バージインはRMS+継続フレーム数でシンプルに判定
- デバイス変更時は無理に継続せず安全側へ停止
という構成です。
LocalVQEそのものについては、今回の記事ではモデルの内部構造よりも、**「リアルタイムアプリに組み込む際に何が問題になったか」**に焦点を当てました。
音が「いつ」鳴り、「いつ」マイクに入り、「いつ」処理されたのか。
リアルタイム音声処理では、この時間軸をきちんと扱うことが非常に重要だと改めて感じました。