Android
GoogleCloudPlatform
gRPC
CloudSpeechAPI

Cloud Speech API + gRPC + Streaming音声解析をAndroidから利用する

More than 1 year has passed since last update.

@eaglesakura です。

Cloud Speech APIは、ざっくりといえばGoogle謹製の音声認識APIです。

まだBeta版ですが、非常に良い精度の音声認識(テキスト化)を行ってくれます。

特にStreamingで音声の逐次解析(例えばマイクで集音しながら音声をサーバーに送信し、解析経過をリアルタイムで受け取る)が面白いですが、行なうためには前提が色々と大変なのでざっくりとした手順を書きます。


GCPプロジェクトを用意する

利用するためにはGoogleログインが必要ですので、 APIコンソールからプロジェクトを作成します。

作成後は、APIManager > 認証情報からOAuth2.0クライアントIDを登録します。その時、アプリpackage名と署名鍵のSHA1を登録するのを忘れると認証が正常に行えないので注意してください。

簡単にやるなら、Firebase Projectとリンクしてしまい、FirebaseからAndroidアプリを登録すると全部やってくれてgoogle-services.jsonも取得できます。


AndroidでOAuth2認証を行なう

Google Play Services / Authライブラリを使えば簡単に行なえます。

dependencies {

// authライブラリを追加する
// バージョンは適宜設定。
compile "com.google.android.gms:play-services-auth:${ANDROID_PLAYSERVICE_LIB_VERSION}"
}

// play-serviceのプラグインも利用する
apply plugin: 'com.google.gms.google-services'

    // 認証用のオプションはこうなる

// Scopeに`https://www.googleapis.com/auth/cloud-platform`を追加するのを忘れないように。
public static GoogleApiClient.Builder newFullPermissionClient(Context context) {
GoogleSignInOptions options = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestScopes(new Scope("https://www.googleapis.com/auth/cloud-platform"))
.requestIdToken(context.getString(R.string.default_web_client_id))
.requestEmail()
.build();
return new GoogleApiClient.Builder(context)
.addApi(Auth.GOOGLE_SIGN_IN_API, options)
;
}

    // 認証後のOAuthトークンはこう取り出す

String getOAuth2Token() throws Throwable {
return GoogleAuthUtil.getToken(getContext(), new Account("your.name@example.com", "com.google"), "oauth2:https://www.googleapis.com/auth/cloud-platform");
}


gRPC用のプロジェクトを作成する

Cloud Speech APIはGoogleの開発したファイルフォーマットであるProtocol Buffers(以下protobuf)でサーバーと対話します。

使用するためには、Cloud Speech API用の*.protoファイルをコンパイルし、制御用のコードを生成しなければなりません。

また、生成されるソースコードはフルスペック版とLite版があり、Androidでは後者が推奨されています。しかしCloud Speech API for JavaのクライアントはフルスペックのJava向けにビルドされているようで、Androidでは利用できません。

そのため、protobuf-lite版を自力で出力することにしました。ちなみに、Androidプロジェクトに直接記述する方法では正常に動作しない(少なくとも私の構成の場合)ので、出力専用のGradleプロジェクトを作っています。

protobufは3.1.0がリリースされていますが、lite版は3.0.1までしか対応しておらず、なおかつプラグインの依存しているprotobufに3.0.1が存在しないというメンドウな依存関係から、最終的にprotobuf3.0.0を使用しています。

また、一部のドキュメントで compile "io.grpc:grpc-core:${gRPC_VERSION}" を追記し忘れているようで、コピペすると痛い目を見ます。

必要な.protoファイルはこのリポジトリから取得できます。そのまま使うとライブラリの巨大さがますので、Cloud Speech APIに関連するものだけをコンパイルするほうが良いです。具体的にはv1v2のようなバージョニングされているディレクトリに入っている.protoファイルはAPIのインターフェース定義なので、不要なものは削除して問題ありません。

ただし、lite版では標準ライブラリ的なファイルが同梱されていないので、必要なファイルをProtocol Buffers本体のリポジトリ から拾ってきます。

buildscript {

repositories {
mavenCentral()
}
dependencies {
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.0'
}
}

apply plugin: 'java'
apply plugin: 'com.google.protobuf'

repositories {
mavenCentral()
}

def gRPC_VERSION = "1.0.1"
def PROTOBUF_VERSION = "3.0.0"

dependencies {
compile "io.grpc:grpc-core:${gRPC_VERSION}"
compile "io.grpc:grpc-okhttp:${gRPC_VERSION}"
compile "io.grpc:grpc-protobuf-lite:${gRPC_VERSION}"
compile "io.grpc:grpc-stub:${gRPC_VERSION}"
}

// 適宜liteに書き換えている
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:${PROTOBUF_VERSION}"
}
plugins {
grpc {
artifact = "io.grpc:protoc-gen-grpc-java:${gRPC_VERSION}"
}
javalite {
artifact = "com.google.protobuf:protoc-gen-javalite:${PROTOBUF_VERSION}"
}
}
generateProtoTasks {
all()*.plugins {
javalite {}
grpc {
// Options added to --grpc_out
option 'lite'
}
}
}
}

この状態で gradle generateProtoを実行すると、lite版にリンクされたソースコードが build/generated/source/proto/main/javalite/ に出力されるので、それをAndroidプロジェクトにコピペなりして持ってきます。

ただし、Androidには javax.annotation.Generated Annotationが存在しないので、そのままだとコンパイルエラーになります。とりあえず適当な同等のAnnotationを作ってあげれば動作します。

package javax.annotation;

public @interface Generated {
String value() default "";

String comments() default "";
}


gRPCのライブラリをリンクする

gRPCのライブラリ自体は適切にリンクすれば正常動作します。

dependencies {

compile "io.grpc:grpc-core:${gRPC_VERSION}"
compile "io.grpc:grpc-okhttp:${gRPC_VERSION}"
compile "io.grpc:grpc-protobuf-lite:${gRPC_VERSION}"
compile "io.grpc:grpc-stub:${gRPC_VERSION}"
}


MultiDexを有効化する

リンクされるライブラリは全部で約8万メソッドくらいあります。そのため、MultiDexを有効化しないと絶対にビルドが通りません。

これでようやく通信の準備が整いました。

android {

defaultConfig {
multiDexEnabled true
}
}


gRPCでGoogleサーバーと通信する

gRPCそれ自体はドキュメントを読んだり別な記事を参照するほうが良いかと思います。


Channelを開く

Androidの場合はOkHttpのライブラリを使うのが楽です。

        ManagedChannel channel = OkHttpChannelBuilder.forAddress("speech.googleapis.com", 443)

.connectionSpec(ConnectionSpec.COMPATIBLE_TLS)
.enableKeepAlive(true)
.negotiationType(NegotiationType.TLS)
.build();


メソッドStubを生成する

必要なメソッド等はprotobufが自動的に生成してくれています。

ストリーミングを行う場合は次のようにStubを取得します。

            SpeechGrpc.SpeechStub speech = SpeechGrpc.newStub(channel);

assertNotNull(speech);


OAuth2認証を行なう

この記事を参考に認証を行いました。

このとき、MetadataUtils.attachHeadersメソッドの戻り値は別インスタンスになるので注意しましょう(古いインスタンスは未認証のままです)。

                Metadata headers = new Metadata();

Metadata.Key<String> authorization = Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
headers.put(authorization, "Bearer " + getOAuth2Token());
speech = MetadataUtils.attachHeaders(speech, headers);


ストリーミングを開始する

ストリーミングは次のように行います。

引数にはストリーム受信用のObserverを渡すと、ストリーム送信用Observerが返却されます。返却された値に対してデータを投げつけると、逐次結果をコールバックしてくれるようになります。

            StreamObserver<StreamingRecognizeRequest> speechStream = speech.streamingRecognize(new StreamObserver<StreamingRecognizeResponse>() {

@Override
public void onNext(StreamingRecognizeResponse value) {
AppLog.test("Streaming onNext type[%s] results[%d] error[%s]", value.getEndpointerType(), value.getResultsCount(), value.getError());
validate(value.getResultsList())
.allNotNull()
.each(resultItem -> {
validate(resultItem.getAlternativesList()).allNotNull().each(alter -> {
AppLog.test("Conf[%.2f] Trans[%s]", alter.getConfidence(), alter.getTranscript());
});
});
}

@Override
public void onError(Throwable t) {
t.printStackTrace();
}

@Override
public void onCompleted() {

}
});


設定データを送信する

上記のストリーミングを開始したら、最初に1度だけConfig情報を送信します。これがハマりどころで、毎度Config情報をつけると解析エラーになります。

                    // 設定コマンドを送信

speechStream.onNext(StreamingRecognizeRequest.newBuilder().setStreamingConfig(
StreamingRecognitionConfig.newBuilder().setConfig(RecognitionConfig.newBuilder()
.setEncoding(RecognitionConfig.AudioEncoding.LINEAR16)
.setSampleRate(AUDIO_SAMPLE_RATE)
.setLanguageCode("ja-JP")
)
.setSingleUtterance(false) // 1会話で解析をabortするならtrue
.setInterimResults(true) // 途中経過を受け取るならtrue
).build());


音声データを収集・送信する

Config送信後は音声データそのものを送信します。

送信データは RecognitionConfig.AudioEncoding.LINEAR16 の場合は生データになりますので、リトリエンディアンの16bit波形データをそのまま送りつければ問題ありません(Wavファイルヘッダを付与したりする必要はない)

波形データはAndroidであればマイクから簡単に取得できます。

        final int AUDIO_SAMPLE_RATE = 16000   // サンプリング16khz

final int AUDIO_BUFFER_BYTES = 1024 * 8; // バッファリングする切り上げサイズ

int RAW_BUFFER_SIZE = AudioRecord.getMinBufferSize(AUDIO_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
int CAPTURE_CACHE_SIZE = (((RAW_BUFFER_SIZE * 4) / AUDIO_BUFFER_BYTES) + 1) * AUDIO_BUFFER_BYTES;

short[] audioBuffer = new short[CAPTURE_CACHE_SIZE / 2];
AudioRecord record = new AudioRecord(MediaRecorder.AudioSource.MIC, AUDIO_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, CAPTURE_CACHE_SIZE);

AppLog.test("start recording...");
record.startRecording();

while(true) {
// 波形データを読み込み続ける
record.read(audioBuffer, 0, audioBuffer.length, AudioRecord.READ_BLOCKING);
}

取得した波形はLittle Endianに変換しなければなりません。変換はnio.ByteBufferが行ってくれるので簡単です。


// Little Endianのバッファを用意する
ByteBuffer nativeOriginBuffer = ByteBuffer.allocateDirect(CAPTURE_CACHE_SIZE).order(ByteOrder.LITTLE_ENDIAN);
ShortBuffer nativeBuffer = nativeOriginBuffer.asShortBuffer();

// short[]をNative Bufferに書き込む
nativeBuffer.position(0);
nativeBuffer.put(audioBuffer, 0, audioBuffer.length).position(0); // Little Endianに書き出す
nativeOriginBuffer.position(0);

// nio.BufferからのデータをAudioContentとしてストリームに投げる
// ここでConfigをつけるとエラーになるので、AudioContentのみを指定する
StreamingRecognizeRequest request = StreamingRecognizeRequest.newBuilder()
.setAudioContent(ByteString.copyFrom(nativeOriginBuffer))
.build();
speechStream.onNext(request);


後片付け

セッション握りっぱなしはよくなさそうなので、開いたChannelは適宜シャットダウンしましょう。

            channel.shutdownNow().awaitTermination(1000 * 60, TimeUnit.MILLISECONDS);


Proguard

ライブラリは超巨大なので、Proguardをしてあげるとだいぶ小さくなります。

最低限、これで動作しました。

ただ、依存しているライブラリのproguard-ruleの影響を受けている可能性があるので、あとは適宜調整してください。

# gRPC

-keepclasseswithmembers class io.grpc.** {
<fields>;
}


まとめ

APIはBeta版で、かつ超巨大なライブラリをリンクしなければ動作しないため、採用するかどうかはちゃんと見極めが必要そうです。

「ストリーミングは使わない」と割り切れるのであれば、普通にREST APIが公開されているのでそっちのほうがコスト少なく導入できます。

ビルド時間やBeta版からのアップデートリスクを金で叩ける層であれば、導入は比較的問題なく行えそうです。