プロローグ
個人開発しているアプリで、キャンバスに書かれた文字を解析して音声再生する機能をリリースしました。 実装した記録をブログとしてまとめます。
久々に個人開発アウトプットした。
— kako@Android開発 (@kako_351) 2021年8月28日
リリース済みアプリをアップデートしました。
手書き文字を音声再生が目玉機能https://t.co/ZIUylEiPWw
構成
全体の構成はこんな感じ。 「ひつだん」は個人開発したアプリの名称です。 FirebaseとGoogleCloudVisionを利用します。
サーバーサイドの処理にはFirebase Functionsを利用します。
実装ステップ
1. CanvasをBitmapにする
「ひつだん」は画面全体をCanvasにしているのでスクリーンサイズを取得してまるごとBitmapを生成します。
// >= Build.VERSION_CODES.R val metrics= DisplayMetrics() activity.display?.getRealMetrics(metrics) val bitmap = Bitmap.createBitmap(metrics.widthPixels, metrics.heightPixels, Bitmap.Config.ARGB_8888)
このままだと最近の端末はサイズが大きいので、圧縮します。
val maxDimension = 1280 val originalWidth = bitmap.width val originalHeight = bitmap.height var resizedWidth = maxDimension var resizedHeight = maxDimension if (originalHeight > originalWidth) { resizedHeight = maxDimension resizedWidth = (resizedHeight * originalWidth.toFloat() / originalHeight.toFloat()).toInt() } else if (originalWidth > originalHeight) { resizedWidth = maxDimension resizedHeight = (resizedWidth * originalHeight.toFloat() / originalWidth.toFloat()).toInt() } else if (originalHeight == originalWidth) { resizedHeight = maxDimension resizedWidth = maxDimension } val resizeBitmap = Bitmap.createScaledBitmap(bitmap, resizedWidth, resizedHeight, false)
val byteArrayOutputStream = ByteArrayOutputStream() resizeBitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream) val imageBytes: ByteArray = byteArrayOutputStream.toByteArray() val base64 = Base64.getEncoder().encodeToString(imageBytes)
2. Google Cloud Visionで文字認識する
サーバーサイドの処理を書きます。 今回はFirebase Functionsを利用したので、以下のように実装してます。
import * as functions from "firebase-functions"; import vision from "@google-cloud/vision"; const client = new vision.ImageAnnotatorClient(); export const annotateImage = functions.region(YOUR_REGION).https.onCall(async (data, context) => { try { return await client.annotateImage(JSON.parse(data)); } catch (e) { throw new functions.https.HttpsError("internal", e.message, e.details); } });
Vision クライアント ライブラリを利用したので簡単です。 ただしベータ版なので規約には注意が必要です。
https://cloud.google.com/vision/docs/libraries?hl=ja#client-libraries-install-nodejs
3. TextToSpeechで音声再生する
GoogleCloudVisionから返された結果をTextToSpeechで再生します。
val tts = TextToSpeech(context, this /* TextToSpeech.OnInitListener */) tts.speak(message, TextToSpeech.QUEUE_FLUSH, null, "messageID") tts.shutdown()
これだけでも音声再生されるのですが、実用にはもっとケアが必要です。
OnInitListenerで初期化を検知
TextToSpeech.OnInitListener
でTextToSpeechの初期化に成功しているかチェックします。
失敗した場合はstatusに入るのでエラー処理します
override fun onInit(status: Int) { if(status == TextToSpeech.SUCCESS) { // 初期化成功 } else if (status == TextToSpeech.ERROR_SERVICE) { // この端末ではTextToSpeechは提供されてません。 } else { // その他のエラー処理 } }
日本語で再生されるように設定
初期化に成功したら言語の設定をします。端末依存するので最低限の処理として書いてあげます。
今回は日本語利用前提なので日本語に設定しますが、設定するまえに isLanguageAvailable
で利用可能かチェックします。
if(status == TextToSpeech.SUCCESS) { val locale = Locale.JAPAN if (tts.isLanguageAvailable(locale) > TextToSpeech.LANG_AVAILABLE) { tts.language = locale } }
UtteranceProgressListenerで再生ステータスを検知
utteranceId
にはspeak
に渡した識別子が入ります。
tts.setOnUtteranceProgressListener(object : UtteranceProgressListener(){ override fun onDone(utteranceId: String?) { Timber.d("onDone id=$utteranceId") } override fun onError(utteranceId: String?) { Timber.d("onError id=$utteranceId") } override fun onStart(utteranceId: String?) { Timber.d("onStart id=$utteranceId") } })
実際はもっと細かくいろいろしてますが、とりあえず必要なのはこの辺でしょうか。
実際の挙動
Google Cloud Visionの精度は結構高いと感じました。 ただし、日本語での縦書きとなるとだいぶくるしい解析結果なので、横書きでの利用がおすすめです。
手書き文字を解析して音声再生こんな感じ pic.twitter.com/2DLnz4fQtK
— kako@Android開発 (@kako_351) 2021年8月28日
アプリこちら
今回この機能を実装したアプリはこちら。 ま、この機能は有料なんですけどね。 play.google.com