やりたいこと
Androidアプリで特定のキーワード(ウェイクワード)をオフラインで検出したい。
クラウドの音声認識API(Whisper等)は汎用的で強力だが、ウェイクワードのような短い定型コマンドには向いていない。理由は2つ。
- レイテンシ:ラウンドトリップで1〜2秒かかる。呼びかけてから反応するまでの間に次の行動が始まる
- コスト:数文字のコマンドのためにAPIを毎回呼ぶのは無駄が大きい
Voskの**文法モード(Grammar mode)**を使うと、認識対象を登録した単語リストのみに限定できる。誤検知が減り、推論も軽くなる。
この記事では以下の2点を実装する。
- Voskの文法モードでウェイクワードとコマンドを検出する
-
HandlerThreadでVosk推論をスレッドセーフにする
環境
- Android (minSdk 26 以上推奨)
- Kotlin 1.9+
- Vosk Android ライブラリ
0.3.47 - 日本語モデル:
vosk-model-small-ja-0.22(assets内に配置)
文法モードとは
Voskには2つの認識モードがある。
| モード | 説明 | 向いている用途 |
|---|---|---|
| 全語彙モード | モデルが知っている全語彙を認識する | ディクテーション・議事録など |
| 文法モード | 登録した単語リストのみを認識する | ウェイクワード・コマンド検出 |
文法モードでは認識対象が絞られるため、登録外の語彙が誤検知として返ってこない。ウェイクワード検出に使うと精度が大幅に上がる。
実装
1. コマンド定義
// 検出する音声コマンドの種類を定義
enum class VoskCommand {
WAKE_WORD, // ウェイクワード(任意に設定可)
NEXT, // 「次」「続き」コマンド
REPEAT // 「もう一度」「もう一回」コマンド
}
2. 設定クラス
data class WakeWordConfig(
// ウェイクワードは任意の日本語語句を設定できる
val wakePhrase: String = "アシスト",
// Voskは発音の揺れを複数バリアントで吸収する
// 例: 「アシスト」と「あしすと」を両方登録しておく
val wakePhraseVariants: List<String> = listOf(
"アシスト", "あしすと", "アシスト "
),
val modelAssetPath: String = "vosk-model-small-ja",
val sampleRate: Int = 16_000,
// ウェイクワード後にタイムアウトするまでの時間 (ms)
val armedTimeoutMs: Long = 10_000L,
// 連続誤検知を防ぐ冷却期間 (ms)
val cooldownMs: Long = 1_000L,
// 「次」コマンドのバリアント
val nextCommandVariants: List<String> = listOf(
"次", "つぎ", "続き", "つづき"
),
// 「もう一度」コマンドのバリアント
val repeatCommandVariants: List<String> = listOf(
"もう一度", "もう一回", "もう 一度", "もう 一回"
)
)
3. 文法JSONの構築
文法モードに渡す語彙リストをJSON配列として組み立てる。
private fun buildGrammar(config: WakeWordConfig): String {
// 全バリアントを1つのリストにまとめる
val allVariants = config.wakePhraseVariants +
config.nextCommandVariants +
config.repeatCommandVariants
// JSON配列に整形する
val entries = allVariants.map { "\"$it\"" } + "\"[unk]\""
return "[${entries.joinToString(", ")}]"
// 出力例:
// ["アシスト", "あしすと", "アシスト ",
// "次", "つぎ", "続き", "つづき",
// "もう一度", "もう一回", "もう 一度", "もう 一回", "[unk]"]
}
重要: [unk] は必ず含めること。
[unk]を省略すると、文法に登録されていない音声を受け取ったときにVoskが結果を返さなくなる(内部でループするような挙動)。登録外の音声は "[unk]" として安全に返す役割を果たす。
4. Recognizerの初期化
// モデルをassetsから展開・ロードする
StorageService.unpack(
context,
config.modelAssetPath, // assetsフォルダ内のパス
MODEL_OUTPUT_DIR, // 展開先ディレクトリ名
{ model ->
// コールバックの引数は String パスではなく org.vosk.Model オブジェクト
val grammar = buildGrammar(config)
// 文法モードで Recognizer を初期化する
// 第3引数に grammar JSON 文字列を渡すのがポイント
recognizer = Recognizer(model, config.sampleRate.toFloat(), grammar)
onReady()
},
{ exception ->
onError(IOException("Voskモデルの展開に失敗", exception))
}
)
5. HandlerThreadでスレッドセーフにする
Voskは スレッドセーフではない。複数スレッドから acceptWaveForm() を呼ぶとクラッシュする。
HandlerThread を使ってメッセージキューに直列実行させる。
// HandlerThread: メッセージキューを持つ専用スレッド
// 投入されたタスクを1つずつ直列に処理するため、Voskのシリアライズに最適
private val voskHandlerThread = HandlerThread("VoskThread").also { it.start() }
private val voskHandler = Handler(voskHandlerThread.looper)
fun processFrame(audioData: ShortArray) {
// post() でタスクをキューに投入 → HandlerThread が順番に処理する
voskHandler.post {
recognizer?.let { rec ->
// acceptWaveForm の引数は ShortArray(ByteArrayではない)
if (rec.acceptWaveForm(audioData, audioData.size)) {
// 発話終了時に最終認識結果が返る
val result = JSONObject(rec.result)
val text = result.optString("text", "")
handleResult(text)
}
// 発話中は部分結果 rec.partialResult で随時取得可能
}
}
}
Pythonとの違いメモ: Pythonの queue.Queue + worker thread パターンに近い。Handler.post() でタスクを積んで、HandlerThread が1つずつ処理する。
6. コマンド分類ロジック
fun classifyCommand(recognizedText: String): VoskCommand? {
// 優先順位: WAKE_WORD → NEXT → REPEAT の順で判定
if (isWakeWordMatch(recognizedText)) return VoskCommand.WAKE_WORD
if (isNextCommandMatch(recognizedText)) return VoskCommand.NEXT
if (isRepeatCommandMatch(recognizedText)) return VoskCommand.REPEAT
return null // 登録外([unk])が返ってきた場合はnull
}
fun isWakeWordMatch(recognizedText: String): Boolean {
// Voskは日本語の分かち書きを自動挿入することがある
// "もう 一度" と "もう一度" が別文字列として返るケースを空白除去で統一する
val normalized = recognizedText
.replace(" ", "") // 半角スペース除去
.replace(" ", "") // 全角スペース除去
.trim()
val target = config.wakePhrase
.replace(" ", "")
.replace(" ", "")
.trim()
if (normalized == target) return true
// バリアントとも照合する
return config.wakePhraseVariants.any { variant ->
val normalizedVariant = variant.replace(" ", "").replace(" ", "").trim()
normalized == normalizedVariant
}
}
よくあるハマりどころ
ハマり1: Voskがクラッシュする
症状: 数回認識後に java.lang.IllegalStateException でクラッシュ
原因: Recognizer.acceptWaveForm() をCoroutine(Dispatchers.IO)や複数スレッドから同時に呼んでいる。Voskのドキュメントには "Not thread-safe" と記載されている。
解決策: 上記の HandlerThread + Handler.post() パターンで直列実行する。
ハマり2: [unk] を省いたら認識されなくなった
症状: しばらくすると突然ウェイクワードが一切認識されなくなる。Voskが空文字列を返し続ける。
原因: 文法JSON から "[unk]" が消えていた。
解決策: 文法JSONの末尾に必ず "[unk]" を追加する(前掲の buildGrammar() 参照)。
ハマり3: acceptWaveForm() の引数型
症状: コンパイルエラーまたは意図しない結果
原因: acceptWaveForm() の第1引数は ShortArray。ByteArray ではない。
解決策: AudioRecord から直接 ShortArray で読み取る。
// AudioRecord から ShortArray で読み取る例
val audioBuffer = ShortArray(bufferSize / 2)
val readCount = audioRecord.read(audioBuffer, 0, audioBuffer.size)
if (readCount > 0) {
processFrame(audioBuffer)
}
まとめ
| ポイント | 内容 |
|---|---|
| 文法モードの有効化 |
Recognizer(model, sampleRate, grammarJson) の第3引数に JSON 文字列を渡す |
[unk] の追加 |
必須。省略するとVoskが固まる |
| スレッドセーフ化 |
HandlerThread + Handler.post() で直列実行する |
| 日本語の分かち書き | Voskは空白を自動挿入する。比較前に空白を除去する |
| 引数型 |
acceptWaveForm() は ShortArray。ByteArray は不可 |
Vosk文法モードはウェイクワード検出のようなコマンド認識に非常に向いている。クラウドAPIなしでオンデバイスで動作するため、レイテンシ・コスト両面で優れた選択肢だ。
参考リンク: