スマホでJARVISを作りたかった ── 完全オンデバイスでX投稿を生成するアプリ「Murmr」を作った話
はじめに:目標はJARVIS
最終的に作りたいのは、スマホの中だけで完結する「JARVIS」だ。クラウドに何も投げず、手元の端末で考えて、喋って、絵を描く相棒。プライバシーもレイテンシも通信料も、全部こっち側で握る。映画みたいなやつを、本気で手元で動かしたい。
ただ、いきなりフル機能のオンデバイス・アシスタントに突っ込むのは無理がある。文章生成と画像生成、この2つをクラウドゼロで、しかも実機(手のひらのAndroid)で実用速度で回す ── まずここが踏めるかどうかが全ての前提になる。
そこで前段階の検証台として作ったのが Murmr だ。「ひとことの呟きを入力すると、オンデバイスのLLMがX向けのポスト文に整形し、同時にオンデバイスの画像生成で添える絵まで作る」アプリ。文章と画像、両方のオンデバイス推論を1つのアプリに同居させる ── JARVISに必要な要素技術を、現実的な小さい題材で全部踏みにいった。
この記事は、その過程で踏んだ地雷とその回避策を、後続のエンジニアが同じ穴に落ちないようにまとめたものだ。
- 端末: ASUS ROG Phone 9 Pro(Snapdragon 8 Elite / SM8750、GPU Adreno 830、Hexagon NPU)
- アプリ: Kotlin + Jetpack Compose、
applicationId = jp.co.produs.xposter - 構成: 文章 = Gemma 3n E4B(MediaPipe LLM Inference)/ 画像 = SDXL(Illustrious) を Hexagon NPU で実行
- 到達点: 完全オンデバイス・クラウド非依存。文章も画像も実機で動作確認済み
パート1:文章生成 ── MediaPipe LLM Inference + Gemma 3n E4B
構成
文章側は Google の MediaPipe LLM Inference(com.google.mediapipe:tasks-genai)に Gemma 3n E4B(int4 量子化、.task 形式、約4.4GB)を載せて回している。最終的には実用品質まで到達した。
地雷1:tasks-genai 0.10.24 ではモデルが native クラッシュする
最初、Maven 上で見えていた 0.10.24 を「最新」だと思い込んで使ったところ、Gemma 3n E4B も Qwen も native SIGABRT で即死した。
実際には 0.10.24 は最新ではなく、Google Maven には 0.10.29 / 0.10.32 / 0.10.33 / 0.10.35 が存在する。0.10.35 に上げたら E4B がそのまま動いた。
これは事実上「LiteRT-LM ランタイムへの載せ替え」に相当する。Android 向けに LiteRT-LM 単体の AAR は配布されておらず(GitHub にあるのは iOS の xcframework のみ)、新ランタイムは genai の新しいバージョンに同梱される形で降ってくる。「動かない=モデルが悪い」と決めつける前に、まず genai のバージョンを疑え、というのがここの教訓。
地雷2:.task のフォーマット違いで Unable to open zip archive
Android の LlmInference が受け付ける .task は zip バンドル形式でなければならない。HuggingFace などで配られている -web 版(生の flatbuffer / TFL3)を渡すと Unable to open zip archive で落ちる。
- HF
litert-community/Gemma3-4B-ITは-webのみ = Android 不可 - Android 用の 4B は Kaggle の
google/gemma-3認証ルートから取得する - HF
litert-community/Gemma3-1B-ITは Android 用.task(gemma3-1b-it-int4.task、約0.55GB)があり動く。ただし 1B は品質が粗く(数字を盛る/稀に中国語が混入)実用外
加えて HF の Gemma はリポジトリごとに個別のライセンス同意が要り、fine-grained トークンに「Read access to public gated repos」権限を付けないと 403 になる。
地雷3:APKにモデルを同梱できない問題
4.4GBのモデルをAPKに同梱するのは非現実的。さらにこの端末(ASUS)では外部ストレージのディレクトリがアプリから見えないという罠があり、最終的にモデルは内部の filesDir に置くことにした。
配置手順は adb push で /data/local/tmp に置いてから run-as でアプリの filesDir/models/ へ cp する、という2段構え(外部dirが不可視なため直接置けない)。
地雷4:絵文字が文字化けする(byte-fallbackトークナイザ)
出力に文字化けした絵文字や �(U+FFFD 置換文字)が混ざることがあった。
原因は Gemma の byte-fallback トークナイザが絵文字をバイト単位で扱い、detokenize 時の再結合に失敗して、壊れた絵文字シーケンスや lone surrogate(割れた UTF-16 サロゲート)が残ること。アプリ側のトークン分断ではない(generateResponse は同期で一括取得している)。モデル内部の挙動なので根絶は不可能。
対処は出力をサニタイズして壊れた絵文字だけ落とすこと。TextSanitizer を1枚かませて、� と未ペアのサロゲートを除去し、正しくペアになっている絵文字と通常テキストはそのまま通す。generateResponse() の戻り値をこれに通してから trim する。これで実機の文字化けは解消した。
パート2:画像生成 ── MediaPipeで挫折し、NPUへ逃げた話
ここが一番ハマった。そして一番おもしろい。
地雷5:MediaPipe Image Generator は最新Adrenoで動かない
最初の方針は MediaPipe の Image Generator(tasks-vision-image-generator)で SD1.5 を回すことだった。SD1.5 → MediaPipe形式への変換、push、初期化(Environment / TextGuidance / Copier)までは到達する。が、UNet重みのGPUテクスチャ生成で死ぬ:
diffuser_gpu.cc: clCreateImage: Invalid image size
原因は、MediaPipe Image Generator が依存している ml_drift の OpenCL バックエンドが最新の Adreno(830)に未対応だということ。このライブラリは2025-07で更新が止まっている(レガシー化)。OpenCL 自体は端末に存在しロードもできる(Manifest に uses-native-library libOpenCL.so も追加した)が、それでも通らない。
pivot:Hexagon NPU(QNN)へ
GPU(OpenCL)がダメなら NPU(Hexagon)がある。ここで OSS の Local Dream が持っている QNN 推論エンジンを流用させてもらった。Qualcomm の QNN 経由で Hexagon NPU 上で Stable Diffusion を回すネイティブエンジンだ。
構成はこうなった:
- Local Dream の APK から抽出した native エンジン
libstable_diffusion_core.soをjniLibs/arm64-v8a/に同梱 - これがアプリ内のサブプロセスとして
127.0.0.1:8081に HTTP サーバを立てる - アプリ(
QnnImageGenerator)がPOST /generateを叩く。レスポンスは SSE で、最終event: completeに base64 の生RGBが乗ってくる - エンジンの
.soはuseLegacyPackaging=trueで APK 内で非圧縮展開して exec する
地雷6〜9:QNN統合で踏んだ4つの罠
ここからが本番。NPU経由で実際に画像が出るまでに踏んだ地雷を残しておく。
(1) 起動時に SIGSEGV (0x28)
起動引数の --clip は 必ず文字列 "clip.mnn" で終える必要がある。エンジンの main.cpp は、その名前で渡されたときだけ隣の clip_v2.mnn を自動採用し、pos_emb / token_emb を読み込むという挙動になっていた。clip.mnn という実体ファイルは不要で、純粋に文字列のトリガーになっている(ソースを読まないと絶対分からないやつ)。
(2) DSP の createUnsignedPD / skel 1002 エラー
これは run-as ドメインの制約だった。run-as 経由で起動すると DSP が使えない。untrusted_app ドメインで動く実アプリとして起動すると DSP が通る。つまり検証は必ず実アプリで行うこと。adb の run-as でいくら叩いても再現しない(し、直らない)。
(3) 生成がタイムアウトする
localhost への平文HTTPがブロックされていた。Manifest に usesCleartextTraffic="true" を入れて、localhost の平文通信を許可する。
(4) 画像がノイズになる
エンジンが返してくるのは 生RGB(3byte/px 固定)。channels フィールドに惑わされず、stride = i*3 決め打ちでデコードする。ここを真面目に channels で計算すると壊れる。
地雷10:DSPは1リクエストずつ
エンジンは1リクエストずつしか捌けない。アプリ側の再生成と、検証用の外部 POST を同時に叩くと native クラッシュする(実際に落とした)。並列で殴らないこと。
パート3:SD1.5 → SDXL へ世代アップ
SD1.5(AnythingV5)でまず動いたあと、画質を上げるため SDXL(Illustrious v16、アニメ特化) に上げた。結果、1024×1024 を約44秒/枚で、完全オンデバイスで生成できた。
おもしろかったのは、バンドル済みのエンジン .so が既に SDXL 対応だったこと。strings でバイナリを覗いたら Executing sdxl unet graph や Failed load SDXL CLIP1/2 MNN という文字列が見えた。エンジンを抜き直す必要はなく、起動フラグを足すだけで済んだ。
SD1.5 に対して追加した起動引数の差分:
--sdxl --lowram-
--text_embedding_size 768(_2の1280はエンジンが内部設定する) -
--use_cpu_clip(SDXLはCLIPを必ずCPU/MNNで回す) -
--clipには実体のclip.mnnを渡す(SD1.5時代の「文字列トリック」と違い、SDXLではclip.mnn/clip_2.mnnが本物のファイルとして同梱されている)
--lowram は UNet / VAE を生成のたびにロード・退避する省メモリ動作。文章用の Gemma が 4.4GB を占有しているので、画像エンジンとメモリを同居させるための必須フラグだった。ここはオンデバイスで複数モデルを同居させる時の現実的な制約として効いてくる。
モデルは ungated の xororz/sdxl-qnn から illustrious_v16_qnn2.28_8gen3.zip(3.73GB)。中身で注意が要るのは clip_2.mnn が外部重み clip_2.mnn.weight(1.26GB)を別ファイルで持っている点(セットで置かないと動かない)。SoC対応判定は SM8750 / 8850 / 8845 / 8650 を許可。
パート4:ペルソナ ── 「本人」をブレずに出す
投稿に添える絵に、毎回バラバラのキャラが出ては困る。ここで2つのアプローチを試した。
txt2img では「本人」が出せない
タグ(プロンプト)で人物を指定する txt2img では、顔の同一性が保てず毎回別人になる。特定キャラの再現も、本人寄せも、txt2imgでは無理だった(補正のために negative を盛ると、かえって崩れる)。
img2img + 低 denoise が正攻法
解決は img2img。実写の参照画像を渡し、denoise_strength = 0.33 という低い値でアニメ化する。これで「本人らしさ」を保ったまま画風だけ変換できた。
- denoise を下げるほど元写真に忠実。0.5 では別人寄り、0.45 でも顔つきが変わる。0.33 が当たり
- steps=28 / cfg=5 / scheduler=euler_a / size=1024
- img2img には
--vae_encoder vae_encoder.binの指定が必須(無いと img2img が無効になる) - プロトコル的には
/generateの JSON に"image": <base64>と"denoise_strength"を足すだけ
参照画像はアプリに同梱し、起動時に base64 化して /generate に積む。これで「ブレない固定アバター」を投稿のたびに生成できるようになった。
シーン/キャラの2系統
最終的にペルソナは SCENE(人物なし背景) と CHARACTER(内容に合ったキャラ) の2本に整理した。
ここで効いたのが、日本語の入力アイデアを、いったんオンデバイスのGemmaで英語のbooruタグに翻訳してから画像生成に渡すという前処理。SDXL/Illustrious は英語しか読めず、生の日本語を突っ込むと雰囲気タグが暴走して、なぜか「ラピュタ/ナウシカ風の風景」に化けるという珍現象が起きていた。文章LLMを画像生成の前処理にも使う、というオンデバイスならではの合わせ技で解決した。
パート5:安全装置(SFW)は2段構え
公開アプリである以上、不適切画像のブロックは必須。これは2段で守っている。
-
プロンプト側:negative プロンプトの先頭に NSFW タグ群を入れ、positive に
sfwを足す。Illustrious はタグへの反応が強いので、これが主防御になる -
エンジン側:
--safety_checker safety_checker.mnn(224pxの分類器)を渡すと、エンジンが毎枚 NSFW スコアを算出し、閾値(既定0.5)を超えたら画像を白塗りしてブロックする
健全画像の実測スコアは 0.0094 で、誤検知もなかった。プロンプトという「予防」とチェッカーという「検査」の二重化、という発想。
まとめ:JARVISへ向けて得たもの
Murmr という小さなアプリだが、JARVIS に必要な要素技術はひととおり踏めた。
- 完全オンデバイスのLLM文章生成は、適切なランタイム(genai 0.10.35 + Gemma 3n E4B)を選べば実機で実用品質に届く
- オンデバイスの画像生成は、GPU(OpenCL)経路が最新SoCで詰むことがある。NPU(QNN/Hexagon)経路に逃げれば SDXL すら 1024px/44秒で回る
- 複数の重いモデルを1端末に同居させるには、省メモリフラグ(
--lowram)や推論の直列化(Mutex)といった同居の作法が要る - 文章LLMを画像生成の前処理(翻訳)に転用するなど、オンデバイス資源を使い回す合わせ技が効く
特に大きかった設計判断は、画像バックエンドを ImageGenerator interface で隔離していたこと。これがあったから、MediaPipe(GPU) → QNN(NPU) → SDXL という2回のバックエンド総入れ替えを、UIを一切触らずにやりきれた。「動くか分からない要素技術ほど、早めに interface で隔離する」── これは次のJARVIS本体でも効くはずだ。
次は、これらを「呟きを整形する」より対話的な相棒へ束ねていく。スマホの中のJARVISは、思ったより手の届くところにある。
本記事の構成・端末はSnapdragon 8 Elite (SM8750) / Adreno 830 / Hexagon NPU を前提にしています。OpenCL/QNNの可否はSoCに強く依存するため、別端末では挙動が変わる可能性があります。
Discussion