はじめに
エコーキャンセラ(スピーカーの音がマイクに回り込むのを打ち消す処理)が実装されていないAndroidの環境でも、ネットワーク通信を一切使わずSTT(声を文字に変換する処理)・TTS(文字を声に変換する処理)・barge-in(AIが話している最中に人間が話しかけて割り込むこと)が成立する音声アシスタントを作れるようにしたい。
今回、その土台になるライブラリ(local_audio)を webrtcライブラリをベースに作成した。ライブラリを使ってアシスタントアプリそのものを作る話は、次の記事で扱う。
このライブラリを組み込んで動かした例が以下の動画である。マイクからの音声認識・応答生成・音声合成・barge-inが、ネットワーク通信なしで一通り動いている。
動画の中で、AIの発話に出てくる「YouTube」が「ユーチューブ」ではなくアルファベット読みで発音されている箇所がある。これはTTS側の問題で、TTSまわりは次の記事の範囲になるためこの記事では扱わない。
動画で動いているSTT・LLM(応答を考える部分)・TTSは、今回のライブラリの範囲には含まれない。今回のライブラリが担当するのは、WebRTCというビデオ通話用ライブラリの中にあるエコーキャンセラだけを取り出し、通信をせずに端末内の音声前処理として使う部分である。
ライブラリが担う範囲
音声アシスタントを一通り動かすには、次のような処理の流れが要る。
このうち、今回作ったライブラリが担当するのは図のオレンジの箱、WebRTCのAPM(Audio Processing Moduleの略。音声まわりの処理をいくつかまとめた呼び方)だけである。
APMの中には、エコーキャンセラであるAEC3、雑音を抑えるNS、音量を自動調整するAGC2が入っている。VAD(声が出ている区間かどうかを判定する処理)・STT・LLM・TTSは、ライブラリを使う側のアシスタントアプリが用意する部分であり、今回のライブラリのコードには含まれない。
図の下側の点線は、スピーカーに送る音そのものをAPMにも渡していることを表す。エコーキャンセラは「スピーカーから今どんな音が出ているか」を知らないとマイクに回り込んだ音を打ち消せないため、この経路が要る。
音声データの形式は48kHz・モノラル・16bit整数・10ms(480サンプル)単位に固定されている。これ以外の形式を渡すとライブラリ側が処理を拒否する。
なぜAndroidでエコーキャンセラが必要なのか?
AndroidのベースになっているAOSP環境の中には、エコーキャンセラが実装されていないものがある。
エコーキャンセラがないと、AIがスピーカーから話している声がそのままマイクに回り込み、AI自身の声を人間の発話だと誤認識したり、人間がAIの発話中に話しかけて割り込む(barge-in)ことを正しく検出できなくなる。
音声アシスタントとして会話のキャッチボールを成立させるには、AIが話している間もマイクの入力を監視し続け、人間の声だけを拾い分けられる必要がある。今回のライブラリが受け持つのは、その拾い分けを実現するための音声前処理の部分である。
WebRTCライブラリの音声処理だけ切り出して使う
WebRTCは本来、ブラウザやアプリ同士でビデオ通話・音声通話をするための通信ライブラリである。ただしAPMは、PeerConnection(通話そのものを成立させる仕組み)などの通信スタックとは独立して呼び出せる作りになっている。この分離を利用し、通信を一切せずAPMだけをリンクして、端末内の音声前処理ライブラリとして使うことにした。
この転用がどれだけ軽い変更で済むかは、fork元のWebRTCへの変更量に表れている。upstream(本家WebRTC)のファイルへの変更は、ルートのBUILD.gnに次の4行を足しただけである。
if (is_android) {
# 本fork独自の音響前処理エンジン
deps += [ "local_audio" ]
}
local_audioディレクトリ以下のコード自体は、upstreamのどのファイルにも手を入れていない。
2つのリポジトリの役割
この記事向けに2つのリポジトリを用意した
libwebrtcはWebRTCのfork(local-audioブランチ)で、local_audio/ディレクトリにAPMのラッパとJNI(KotlinからC++を呼び出すための橋渡し)を追加している。これが今回作ったライブラリ本体である。ビルド成果物の.soファイルには、この追加コードとWebRTC本体のAPM実装(AEC3/NS/AGC2)もリンクされて含まれる。upstream側のそのAPM実装自体には手を入れていない。
android-local-voice-agentは、このライブラリが実際に動くかを確認するためのテストアプリである。製品としてのアシスタントアプリではなく、Issue単位で機能を積み上げたデバッグ用のテストハーネスにあたる。ライブラリを使った実際のアシスタントアプリ自体は、別リポジトリ(VoiceInteractionAppSample)で作られており、そちらの開発は次の記事で扱う。
ライブラリを使う側はlocal_audioのソースを自分でビルドする必要はない。ビルド済みの.soファイルをGitHub ReleasesからSHA256検証つきで取得し、Android Gradle Pluginが標準で拾うapp/src/main/jniLibs/arm64-v8a/に置くだけで済む。local_audio自体のコードを変更する場合だけ、x86_64 Linux上で自前ビルドが必要になる(WebRTCのビルド済みclangがLinux_x64向けしか用意されていないため)。
ライブラリの使い方
アシスタント側のアプリからは、JNI経由で次の6つの操作を呼び出す。
| 操作 | 内容 |
|---|---|
create(aec, ns, agc) |
エコーキャンセラなどの有効/無効を指定してインスタンスを作り、handleを返す |
processCapture(handle, input, output) |
マイク側の480サンプルを処理し、エコーを除去した音声を返す |
processRender(handle, input) |
スピーカーに送る480サンプルを、エコーの基準信号として登録する |
setStreamDelayMs(handle, delayMs) |
マイクとスピーカーの実測の遅延をミリ秒で設定する |
reset(handle) |
エコーキャンセラの内部状態を破棄して作り直す |
destroy(handle) |
インスタンスを破棄する |
create()はcaptureスレッド側で1回だけ呼び、返ってきたhandleをrenderスレッドと共有して使う(共有が必要な理由は次の章)。input/outputは480サンプル(960バイト)のDirectByteBufferを呼び出し側で事前に確保し、使い回す。
実装で本当に重要な3点
local_audioの利用時の3つのポイントは下記の通り
-
マイク入力を処理するcaptureスレッドとスピーカー出力を処理するrenderスレッドが別々に動いていても、
LocalAudioProcessorのインスタンスは1つだけ共有する。それぞれのスレッドで別々にインスタンスを作ると、エコーキャンセラの内部状態が2つに分裂して機能しない -
マイクとスピーカーの音声コールバックが呼ばれるたびにメモリ確保をしない。Kotlin側は480サンプル分の
DirectByteBufferをアプリ起動時に1回だけ確保して使い回し、JNI側はGetDirectBufferAddressでそのポインタを取得するだけでコピーも確保しない。コールバックが10msごとに呼ばれる経路でメモリ確保が発生すると処理が詰まりかねないため、それを避ける -
JNI関数の登録方式である。Androidの標準的なビルドでは、
JNI_OnLoad以外のシンボルをversion scriptが隠す。そのため、Java_パッケージ名_クラス名_メソッド名という命名でJNI関数を定義する方式では、そのシンボルがexportされず呼び出せない。local_audio_jni.ccはJNI_OnLoadの中でRegisterNatives()を呼び、関数を明示的に登録する
効果測定
エコーキャンセラが実際にどれだけ効いているかは、ERLE(エコーがどれだけ小さくなったかを表す数値。大きいほど効いている)という指標で確認した。
| 検証環境 | 条件 | ERLE |
|---|---|---|
| Linuxホスト単体(合成エコーを注入) | エコーキャンセラ有効 | 58.5dB |
| Linuxホスト単体(合成エコーを注入) | エコーキャンセラ無効(対照) | 0.3dB |
| エミュレータ+ホストPC(Mac)の実マイク(合成エコーを注入) | エコーキャンセラ有効 | 33.8dB |
Linuxホスト単体での検証では、双方が同時に話す状況(double-talk)でも人間側の音声成分が残ることを確認しており、barge-inが成立する前提は満たされている。60,000フレーム(10分相当)の連続動作と、途中でReset()を呼ぶ操作もASan(メモリ不正を検出するツール)ではエラーなく通っている。
エミュレータ上でホストPCの実マイクを使った検証では33.8dBとLinuxホスト単体の58.5dBより低い値だが、実際のマイク雑音や非線形な音の経路を踏まえれば妥当な差と言える。一方で、実際のスピーカーから出た音がマイクへ戻る物理的な経路でのAEC評価は、エミュレータに音の物理的な経路がないため実施できていない。
.soファイルのサイズは、APM/AEC3をリンクした状態でstripped 863KB(unstripped 10.5MB)である。WebRTCのバージョン文字列だけを含むhello-world版が259KBだったので、これがAPM/AEC3を含めることによる増分にあたる。
残っている作業
エミュレータでは検証できないため、実機での評価が残っている項目がある。実際のスピーカーからマイクへの物理的な音の経路でのAEC品質の評価、EchoCanceller3Config(AEC3の動作を細かく調整するための設定)の実機チューニング、ヘッドセットの抜き差しなどオーディオルートが変わったときの再初期化、実機のCPU/NPU上での処理速度の測定など。EchoCanceller3Configを注入する経路自体はすでに実装済みで、値を差し替えて調整すれば良い。
このライブラリを使ってSTT・LLM・TTSを組み合わせ、実際のアシスタントアプリ(VoiceInteractionAppSample)を動かすところは、次の記事で扱う。
ライセンスとパテント
このforkが使っているWebRTCのコードは、著作権(LICENSE)と特許(PATENTS)の2つの文書で公開条件が定められている。
LICENSEはBSD 3-Clauseで、著作権表示・利用条件・免責事項を保持すれば改変・再配布ができる、という内容である。用途を通信に限定する条件は含まれていない。禁止されているのは、Googleやcontributorの名前を無断で宣伝に使うこと。
PATENTSは「この実装(WebRTCコードパッケージ)の内容」に対する特許ライセンスで、make/use/modify/propagateまでを許諾している。これも用途を通信に限定する記述はない。ただし「この実装をさらに改変した結果としてのみ侵害される請求」は対象外である。今回はAPM/AEC3自体をupstreamから無改変で使っており、新規に書いたのはラッパとJNIのコードだけなので、この除外には該当しない。
以上から、今回のlocal_audioの使い方(APMだけを通信目的以外に転用し、ソースとバイナリの両方を配布する)は、この2つの文書の条件の範囲内だと判断している。実務上の対応として、fork(ソース)にはLICENSE/PATENTSをそのまま同梱しており、.soのGitHub Releaseにも著作権表示・条件・免責事項を追記した。
なお、この整理は条文の読み方であり、最終的な法的判断ではない。


