画面は別アプリのまま、声でBGMを流す ― GradleなしのAndroidアプリにVoskでオフライン音声操作を足した

全画面表示のキオスク的なAndroidアプリに、「話しかけたら反応する」を足したかった。条件は2つ。完全オフライン(音声をクラウドに投げない・ネットが不安定でも効く)で、常時リスニング(ボタンを押さず、ただ喋るだけ)。

採用したのは Vosk。オフラインで動く音声認識エンジンで、日本語の小型モデルもある。ただ、このアプリは Gradleを使わない手動ビルド(aapt2 / javac / d8 / apksigner を直接叩く)で作っているため、ネイティブライブラリ入りのAARを素直に足せず、いくつかの沼を踏んだ。この記事はその実戦記録。

ゴールと構成

最終的に動いた形。

  • 常時リスニング: フォアグラウンドサービスが Vosk で16kHzの連続認識
  • コマンド: 「音楽」→ジャズのネットラジオをアプリ内で再生、「止めて」→停止、「画面戻して」→表示アプリを前面化
  • : 音楽はアプリ内のMediaPlayerで鳴らすので、画面は元の表示アプリのまま・音だけ裏で流れる
  • 完全オフライン(認識部分)=ネットは音楽ストリームにしか使わない

1. GradleなしのビルドにVoskを差し込む

Vosk Android は AAR で配布される。中身は classes.jar(Javaラッパ)と、ABIごとの jni/<abi>/libvosk.so。さらに Vosk は JNA に依存するので、JNA の AAR(libjnidispatch.so 入り)も要る。手動ビルドではこれらを自分で展開して組み込む。

# AARはzip。classes.jar と .so を取り出す
unzip vosk-android-0.3.47.aar -d vosk_ex   # classes.jar, jni/arm64-v8a/libvosk.so
unzip jna-5.13.0.aar          -d jna_ex     # classes.jar, jni/arm64-v8a/libjnidispatch.so

ビルドの要所は3つ。

  • javac の classpath に vosk と jna の classes.jar を渡す
  • d8 の入力にも同じ jar を渡して、依存クラスもまとめて dex 化する
  • APKの lib/<abi>/ に .so を入れる。圧縮のまま入れて、マニフェストの <application>android:extractNativeLibs="true" を付ければ、インストール時にOSが展開してくれる
javac -bootclasspath android.jar -classpath "vosk_ex/classes.jar:jna_ex/classes.jar" ...
d8 --lib android.jar --output out $CLASSES "vosk_ex/classes.jar" "jna_ex/classes.jar"
# 署名前のAPKに native libs を追加
cp vosk_ex/jni/arm64-v8a/libvosk.so jna_ex/jni/arm64-v8a/libjnidispatch.so apk/lib/arm64-v8a/
( cd apk && zip -ur base.apk lib )

これでインストール後、libvosk.soUnsatisfiedLinkError なくロードできた。d8 は JNA のデスクトップ向けクラスに対して未解決参照を警告するが、--lib android.jar を渡しておけば落ちずに通る。

2. モデルが読めない ― adb push の権限罠

日本語モデル(展開して約95MB)はAPKに同梱すると重いので、端末のアプリ専用外部ディレクトリ /sdcard/Android/data/<pkg>/files/modeladb push した。パスも中身も正しいのに、起動するとこう吐いて止まる。

VoskAPI: Model() does not contain model files.
java.io.IOException: Failed to create a model

原因は権限だった。adb push で置いたファイルは shell 所有になり、ディレクトリが drwxrws--- shell ext_data_rw。ファイル自体は誰でも読めても、ディレクトリに「その他」の実行/読み取りが無く、アプリのUID(u0_a188 等)が中に入れない。Vosk はディレクトリを辿れず「モデルが無い」と判断する。

adb shell chmod -R 777 /sdcard/Android/data/<pkg>/files/model

これで drwxrwxrwx になり、アプリが読めるようになって listening started再起動後もこの権限は維持された(端末のFUSE実装次第ではリセットされ得るので、心配なら初回起動時にアプリ内部ストレージへコピーして読むのが堅い)。

3. 精度の決め手は「文法」

ここが一番の学び。小型モデルでフリーディクテーション(何でも認識)させると、コマンドを言っても無関係な文字列に化けて全く使い物にならなかった。実際のログがこれ。

heard: 生産性上にですよ
heard: えーっと
heard: ん

解決策は grammar(認識語彙を限定)。Vosk は Recognizer に「想定する語のリスト」を渡せて、コマンド&コントロール用途なら精度が桁違いに上がる。

String GRAMMAR =
  "[\"音楽\", \"ジャズ\", \"止めて\", \"ストップ\", \"再生\", \"写真\", \"[unk]\"]";
Recognizer rec = new Recognizer(model, 16000.0f, GRAMMAR);

[unk] は「どれにも当てはまらない発話」の受け皿で、これを入れておくと雑談やノイズをコマンドに誤爆させない。語彙限定にした途端、こうなった。

heard: 音楽   => MUSIC
heard: 止めて => STOP

なお grammar の各語は、モデルの語彙ファイル graph/words.txt(このモデルは約20万語)に存在する必要がある。事前に grep で全コマンド語が含まれることを確認しておくと安心。

4. 常時リスニングの土台

連続認識は Vosk の SpeechService に任せる。マイクを握り続けるので フォアグラウンドサービスにし、Android 14 では foregroundServiceType="microphone" の宣言と RECORD_AUDIO 権限が要る。モデル読み込み(約95MB)は重いので別スレッドで。

String path = new File(getExternalFilesDir(null), "model").getAbsolutePath();
model  = new Model(path);
speech = new SpeechService(new Recognizer(model, 16000f, GRAMMAR), 16000f);
speech.startListening(this);   // onResult(...) でコマンド判定

認識結果(JSON)の text を取り出し、contains で素朴にキーワード一致 → アクション、で十分実用になる。同じコマンドの二重発火は「2秒以内の同一コマンドは無視」のデバウンスで抑える。

5. 画面は別UIのまま、音だけ流す

「音楽」で外部の音楽アプリを起動する方式は、必ず前面に出てしまううえ自動再生も不安定だった。やりたいのは「表示アプリは出したまま、裏で音だけ」。ならばサービス内の MediaPlayer でネットラジオを直接鳴らすのが一番素直で確実だった。

player = new MediaPlayer();
player.setAudioAttributes(/* USAGE_MEDIA / CONTENT_TYPE_MUSIC */);
player.setDataSource("https://example-stream/jazz.mp3");
player.setOnPreparedListener(mp -> mp.start());
player.prepareAsync();   // ストリーミングは非同期準備

サービスは既にフォアグラウンドなので、UIが別アプリでも再生は途切れない。停止は player.stop()/release()。再生/停止が自前のオブジェクトに閉じるので、外部アプリのメディアセッションに依存するより遥かに確実だ。

6. 状態を覚える

最後に、運用スケジュール(指定時刻で画面ON/OFF)と音楽を噛み合わせた。要件は「消灯時に鳴っていたら、点灯時に自動で鳴らし直す。止めていたら鳴らさない」。

SharedPreferences に「音楽ON/OFF」フラグを持ち、

  • 「音楽」=ONを記憶 / 「止めて」=OFFを記憶
  • 消灯(スリープ)時はプレイヤだけ止めてフラグは保持
  • 点灯時、稼働時間内 && フラグON なら自動再生

これで「夜は静かに、朝は昨日の続き」が無設定で実現できた。Activityが singleTask の場合、点灯の再前面化は onCreate ではなく onNewIntent に来るので、復帰トリガはそちらにも置くのがハマりどころ。

まとめ

  • Gradleなしでも、AARから classes.jar.so を抜けば Vosk は組み込める(extractNativeLibs=true を忘れずに)
  • adb push したモデルはディレクトリ権限でアプリが読めないことがある → chmod -R 777
  • 小型モデルはgrammarで語彙を絞ると精度が激変。[unk] で誤爆も防ぐ
  • 「画面は別UIのまま音だけ」はサービス内 MediaPlayerが確実
  • スリープ/ウェイクと連動する状態は SharedPreferences で持ち、onNewIntent の取りこぼしに注意

クラウドに一切頼らず、ただ喋るだけで反応する端末は、作ってみると素直に楽しい。

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

IP: 取得中...
216.73.216.177216.73.216.177