Android 化した Echo Show 5 に「ハロー吉田」と呼びかけたかった

Echo Show 5 (2nd Gen) を LineageOS 18.1 に置き換えた。せっかくなので Alexa ではなく、自分の付けた名前で呼びかけて動く何かにしたい。

「ヘイ、吉田」と言ったら返事をしてほしい。それだけの話だったのだけど、着地したところが思っていたのと違ったので書いておく。

まず USB で繋ぐ

開発者オプションで USB デバッグを ON にして、Arch の PC に挿す。

$ lsusb | grep Lab126
Bus 001 Device 037: ID 1949:0338 Lab126, Inc. Echo Show 5 (2nd Generation)

$ adb devices -l
G091QV05148706XE       unauthorized usb:1-1.1.1

unauthorized は端末側に「USB デバッグを許可しますか」のダイアログが出ている状態。画面で許可すると device に変わる。udev ルールは要らなかった。認識している時点で権限は通っている。

$ adb devices -l
G091QV05148706XE  device  product:lineage_cronos  model:Echo_Show_5__2nd_Generation_  device:cronos

自分で組む前に、探す

最初に考えていた構成はこうだった。

  1. ウェイクワード検出は Porcupine(Picovoice)。Console で日本語のカスタムワードを作って .ppn を assets に置く
  2. 起きたあとのコマンド認識は Vosk の日本語 small モデル
  3. 実行は Home Assistant の /api/conversation/process に文字列を投げる

自前でウェイクワードを学習させるのは最初から捨てていた。データ集めが本体になるやつだから。

で、この構成をそのまま実装したものが既にあった。

README の一行目に書いてある。

An always-listening voice AI assistant for a 32-bit ARM Android 11 (LineageOS 18.1) tablet.

32-bit ARM の Android 11、LineageOS 18.1。 いま手元にある端末の説明文がそのまま書いてあった。

$ adb shell getprop ro.build.version.release   # 11
$ adb shell getprop ro.build.version.sdk       # 30   (butler の minSdk 30)
$ adb shell getprop ro.product.cpu.abi         # armeabi-v7a
$ adb shell getprop ro.lineage.version         # 18.1-20260905-UNOFFICIAL-cronos

app/build.gradle.ktsabiFilters += "armeabi-v7a" も、同梱されている libjulius-bin.so も、そのまま使える。arm64 用にビルドし直す工程が丸ごと消えた。

MIT なので fork した。ウェイクワード検出・OpenAI Realtime API での会話・Home Assistant のデバイス操作・MCP まで入っている。書くことがない。

ウェイクワードの差し替えに、学習は要らなかった

butler のウェイクワードは ハローバトラーヘイバトラー に固定されている。ここを変えたい。

で、中を見たらモデルではなく文法だった。エンジンは Julius(phone-loop 文法デコーダ)と Vosk(文法で語彙を絞った連続 ASR)の 2 つが選べて、どちらも「この語だけを聞く」という指定の仕方をしている。

つまり音素を書けば終わる。scripts/julius-wake/wake.voca はこうなっている。

% WAKE
ハローバトラー	h a r o: b a t o r a:
ハローバトラー	h a r o b a t o r a:
ハローバトラー	h a r o: b a t o r a
ハローバトラー	h e r o: b a t o r a:
ヘイバトラー	h e i b a t o r a:
ヘイバトラー	h e i b a t o r a

1 つの語に発音のゆれを 4 通り並べている。h e r o: があるのは、英語寄りに「ヘロー」と言ったとき用。

名前の付け方には、好みとは別の制約がある

何と呼ばせるかは自由だけど、当たり外れは音で決まる。README にその話が書いてあった。

ヘイバトラー は一度 reject されている。理由は「トークン列が短くて誤検知が多い」。信頼度スコア(CM)だけでは本物と誤爆を分けられなくて、両者のレンジが重なっていた。最終的に「同じ文の中にいくつ <garbage> フィラーが混ざっているか」という構造で切ることにして通した。本物の呼びかけは短く単独で立つ。誤爆は喋りっぱなしの途中に埋まっている。

ここから逆算すると、選ぶ基準はこうなる。

  • 長いほうが安全ヘイ より ハロー のほうがモーラを稼げる
  • 日常会話に出てこない語。butler は「普通の文の中に バトラー が聞こえる」という負例音声をわざわざ用意している
  • 子音が立つ。母音と撥音ばかりだと遠くから喋ったとき潰れる
  • 家の中の他のアシスタントと被らない

「吉田」は苗字なので 2 番目に引っかかる。ただし ハロー を前に付けると話が変わる。コードにこう書いてあった。

/** A hit requires [phrase]'s words to appear as a contiguous run in the
 * recognized token list — a bare "バトラー", or "ハロー [unk] バトラー", must not match. */

隣接していないと通らない。会話に出てくる「吉田」は素通りする。

ハローヨシダh a r o: y o sh i d a の 8 モーラ。もとの ハローバトラー の 7 モーラより長い。しかも Vosk の文法モードは渡した語がモデルの語彙にある必要があるので、よくある苗字であることがむしろ有利に働く。珍しい造語だと語彙に無い可能性がある。

もう 1 つ ハローアヒル も足した。こっちには固有の弱点がある。h a r o: の直後が母音の a で、子音の切れ目がない。Julius の phone-loop だと境界の a がフィラーに吸われて「ハロー」だけに聞こえる可能性がある。保険として短音版を入れてある。

% WAKE
ハローヨシダ	h a r o: y o sh i d a
ハローヨシダ	h a r o y o sh i d a
ハローヨシダ	h a r o: y o sh i d a:
ハローヨシダ	h e r o: y o sh i d a
ハローアヒル	h a r o: a h i r u
ハローアヒル	h a r o a h i r u
ハローアヒル	h a r o: a h i r u:
ハローアヒル	h e r o: a h i r u

Kotlin 側の enum も 1 箇所。

HELLO_YOSHIDA(
    "ハロー吉田 / ハローアヒル",
    listOf("ハロー ヨシダ", "ハロー アヒル"),
    listOf("ハローヨシダ", "ハローアヒル"),
),

wake.dfa は作り直さなくてよかった

Julius は文法を wake.dfa(オートマトン)と wake.dict(語彙)に分けて持つ。手順書どおりなら mkdfa.pl で両方を再生成する。

ところがこの mkdfa.pl が曲者で、README に丸々 1 節が割かれている。dfa_minimize を呼ぶときに無条件で cygpath -w にシェルアウトするので、Cygwin 以外では置換が空文字になって失敗する。しかも exit status を見ていないので、.dfa が 1 バイトも書かれていないのに「generated: wake.dfa ...」と表示される。 成功したように見えて古いファイルが残る。

手で回す手順も書いてあるのだけど、mkfadfa_minimize を Arch に入れるところから始まる。

やる前に考えた。DFA が何から作られるかというと、wake.grammar(規則)と、語彙からカテゴリ名だけを抜いたものだ。

S : NS_B BODY NS_E
BODY : WAKE
BODY : FILLERS WAKE
...

今回変えたのは % WAKE の中身だけ。規則は触っていないし、カテゴリも NS_B / NS_E / WAKE / FILLER の 4 つで同じ。DFA はカテゴリの上を歩くオートマトンなので、語が何語あろうと同一になる。

作り直す必要があるのは wake.dict だけだった。これは wake.voca の単純な変換で、フォーマットは <カテゴリ番号>\t[<語>]\t<音素>

cat = -1
out = []
for line in (d / "wake.voca").read_text(encoding="utf-8").splitlines():
    if not line.strip():
        continue
    if line.startswith("%"):
        cat += 1
        continue
    word, pron = line.split("\t", 1)
    out.append(f"{cat}\t[{word}]\t{pron}")

生成器が正しいかどうかは、変えていない部分が旧ファイルと一致するかで見られる。FILLER のセクション(39 音素ぶん)を diff したら完全一致した。48 行が 50 行になっただけ。

mkfadfa_minimize もインストールせずに済んだ。

ハマったこと

| tail が exit code を持っていく

ビルドを裏で回して、こう書いていた。

./gradlew assembleDebug --console=plain 2>&1 | tail -40

戻ってきた exit code は 0。通ったと思って次に進んだ。

通っていなかった。パイプラインの終了ステータスは最後のコマンドのもので、ここでは tail のものだ。tail は当然 0 を返す。実際の出力の 5 行上には BUILD FAILED と書いてあった。

見出しを読まずに末尾だけ見ていたのが悪い。

set -o pipefail
./gradlew assembleDebug --console=plain 2>&1 | tail -40
echo "exit: ${PIPESTATUS[0]}"

ログを絞るときは、絞った先ではなく絞る前の結果を見る。

raw.githubusercontent.com は LFS の実体を返さない

本当の失敗はこれだった。

fetch-deps: downloading jnas-tri-3k16-gid.binhmm
  % Total    % Received  ...
100    133  100    133   ...
fetch-deps: sha256 mismatch for .tools/julius/jnas-tri-3k16-gid.binhmm

11MB あるはずの音響モデルが 133 バイトで落ちてくる。中を見たら理由が書いてあった。

version https://git-lfs.github.com/spec/v1
oid sha256:5f427fa11189a4d49de9a9ef51e8b1159971ee1cf5e3656f42081cf69dbf0c98
size 11287607

Git LFS のポインタファイル。julius-speech/dictation-kit の音響モデルは LFS 管理で、raw.githubusercontent.com はこれをそのまま返してくる。

面白いのは、ポインタに書いてある oid と、fetch-deps.sh が期待している sha256 が同じ値だということ。LFS の oid は実体の sha256 そのものなので当然なのだけど、おかげで「期待値と同じ文字列が目の前にあるのにチェックが落ちる」という見た目になる。チェックは正しく動いている。実体ではなく住所が来ただけ。

LFS を実体に解決するのは media.githubusercontent.com/media/ のほう。

-JULIUS_MODEL_BASE_URL="https://raw.githubusercontent.com/julius-speech/dictation-kit/$COMMIT/model/phone_m"
+JULIUS_MODEL_BASE_URL="https://media.githubusercontent.com/media/julius-speech/dictation-kit/$COMMIT/model/phone_m"

これで 11,287,607 バイトが落ちてきて sha256 も一致した。誰の環境でも踏むやつなので、これは upstream に投げる。

いまここ

BUILD SUCCESSFUL in 37s
app/build/outputs/apk/debug/app-debug.apk   71.8M
$ adb install -r app/build/outputs/apk/debug/app-debug.apk
Success

ただし、まだ喋りかけていない。 実機でウェイクワードが取れるかは未確認だし、しきい値(WAKE_THRESHOLD = 0.05MAX_FILLERS = 10)はバトラー用に詰めた値のままだ。フレーズが 2 つになったぶん誤検知は足し算になるはずで、測り直しが要る。

instrumentation テストも今は落ちる。音声フィクスチャが「ハロー、バトラー」と喋っているままだから。リポジトリには録音(-e mode record)と評価(scripts/vosk-eval.py / julius-eval.py)の一式が揃っていて、作者は約 92 分の実音声で測って値を決めている。同じことをやらないといけない。

それで、0 から書き始めた

ここまで「探す前に組むな」みたいなことを書いておいて何なんだけど、結局いま自分のアプリを書いている。

Hachi。同じ日に立てて、もう公開している。

Echo Show 5 を Gemini Live API と喋る壁掛けアシスタントにするやつ。Kotlin、MIT、サーバーは持たずに端末から各サービスへ直接つなぐ。いま動くのは時計と空のグラデーション、Gemini Live との音声会話(字幕つき)、天気と雨レーダー、Gemini の function calling で時刻と天気を答えるところまで。次は Home Assistant の MCP サーバに繋ぐ。

ドキュメントも置いてある。

なぜ fork をやめたのか

butler が良くないからではない。むしろ逆で、良すぎて手が出しにくい。

fork して差分を積むという形が続かなかった。今回やったのは音素 8 行と enum 1 つの差し替えで、それでも Settings.kt や instrumentation テストや評価スクリプトまで芋づるで書き換わった。ここから自分の欲しい形に寄せていくと差分は膨らむ一方で、upstream は毎日動いている。fetch-deps のバグを見つけたときに「これは本家に返すやつ」「これは自分の都合」と仕分けしたのだけど、その仕分けが今後ずっと続く。追従のコストを払い続けるか、追従を諦めて fork を死なせるかの二択になる。

そして、そもそも中身の選択が違った。

  • 会話のバックエンドが違う。 butler は OpenAI Realtime API、Hachi は Gemini Live API
  • MCP の置き場所が違う。 butler は OpenAI 側がホストする形を使っているが、Gemini Live に同じ仕組みは無い。なので Hachi は端末側に MCP クライアントを持つ。結果として LAN の Home Assistant に直接つながる
  • 画面が Echo Show なので、待機中に出しておきたいものが自分にはある。960x480 決め打ちで、汎用化しない

どちらが正しいという話ではない。先に道があったから、分岐を選べている。

借りたのはコードより「調べ尽くされた事実」

Hachi の README にも書いたのだけど、butler から持ち帰ったものはコードではない。

いちばん大きいのはこれ。

この端末にはエコーキャンセラが無い。

audio_effects.xmlaec の宣言が無くて、AcousticEchoCanceler.isAvailable() が false を返す。知らずに書くと、モデルが自分の声を聞いて、自分の返答に返答し続ける。Hachi がスピーカーの鳴っている間はマイクを捨てているのは、butler が先にここを踏み抜いているからだ。自分で踏んでいたら、原因に辿り着くまでに何時間か溶かしていたと思う。

ほかに借りているもの。

  • ウェイクワードをモデルではなく文法で持つという判断。これがなければ「カスタムワード = 学習」から抜けられなかった
  • 誤検知を信頼度スコアではなく構造(周囲のフィラー語数)で切るという発想
  • 道具は 1 ファイル 1 つにして、レジストリ越しにだけ見せる形。tools/ の構成はほぼそのまま倣っている
  • 秘密情報は Keystore で暗号化して SharedPreferences に入れる、ログのタグを 1 つに統一する、といった細かい作法
  • 評価を手作業にしない。録音(-e mode record)→ オフライン評価(vosk-eval.py / julius-eval.py)→ 文法調整、が一式スクリプトになっていて、しかも判断の経緯が README に日付入りで残っている

最後のが効いた。「ヘイバトラーを一度 reject して、別のやり方で通し直した」という記録が残っているから、こちらは同じ壁の前で悩まずに済む。動いたコードより、やってみて駄目だったことの記録のほうを持ち帰っている。

ウェイクワードのフェーズでは、butler が育てた Julius の成果物(音響モデルと辞書)をそのまま使わせてもらう予定。

まとめ

やりたかったのは「自分の付けた名前で呼びかけたら返事をする箱」で、それは丸ごと他人が作っていた。しかも想定端末が手元の端末と一致していた。

一度は fork して、動くところまで持っていった。ウェイクワードも差し替わったし、APK も端末に入った。それで分かったのは、この構成なら作れるということと、自分が欲しいのはこれとは少し違うということの両方だった。確かめるために一周した、ということにしておく。

あとバグを 1 つ見つけたので、それは返しておく。

一台の端末のためだけに書いている個人プロジェクトなので、そのつもりで見てください。 @__syumai さん、まじでありがとうございます。