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
自分で組む前に、探す
最初に考えていた構成はこうだった。
- ウェイクワード検出は Porcupine(Picovoice)。Console で日本語のカスタムワードを作って
.ppnを assets に置く - 起きたあとのコマンド認識は Vosk の日本語 small モデル
- 実行は 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.kts の abiFilters += "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 ...」と表示される。 成功したように見えて古いファイルが残る。
手で回す手順も書いてあるのだけど、mkfa と dfa_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 行になっただけ。
mkfa も dfa_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.05、MAX_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.xml に aec の宣言が無くて、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 つ見つけたので、それは返しておく。
- Hachi(自分のほう): https://github.com/polidog/hachi
- ドキュメント: https://polidog.github.io/hachi/
- fork(ウェイクワード差し替えの実験): https://github.com/polidog/butler
- 本家 butler: https://github.com/syumai/butler (MIT)
一台の端末のためだけに書いている個人プロジェクトなので、そのつもりで見てください。 @__syumai さん、まじでありがとうございます。