0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AndroidでVoskの文法モードを使ってウェイクワード検出する

0
Last updated at Posted at 2026-02-27

やりたいこと

Androidアプリで特定のキーワード(ウェイクワード)をオフラインで検出したい。

クラウドの音声認識API(Whisper等)は汎用的で強力だが、ウェイクワードのような短い定型コマンドには向いていない。理由は2つ。

  • レイテンシ:ラウンドトリップで1〜2秒かかる。呼びかけてから反応するまでの間に次の行動が始まる
  • コスト:数文字のコマンドのためにAPIを毎回呼ぶのは無駄が大きい

Voskの**文法モード(Grammar mode)**を使うと、認識対象を登録した単語リストのみに限定できる。誤検知が減り、推論も軽くなる。

この記事では以下の2点を実装する。

  1. Voskの文法モードでウェイクワードとコマンドを検出する
  2. 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引数は ShortArrayByteArray ではない。

解決策: 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()ShortArrayByteArray は不可

Vosk文法モードはウェイクワード検出のようなコマンド認識に非常に向いている。クラウドAPIなしでオンデバイスで動作するため、レイテンシ・コスト両面で優れた選択肢だ。


参考リンク:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?