近況など
最近ラズパイいじり熱が再燃していて、Raspberry Zero 2Wに雨量計、CO2センサ、環境センサをつないで各値をモニタリングし、Grafanaでグラフ化、ということをやっていました。まあ構築中はなかなか楽しい日々だったのですが、割とあっという間に終わってしまい、やや物足りなさを感じていました。
う~ん、何かラズパイを使ってもっと面白いことが出来ないかなあ?とネットを徘徊したりAIに聞いたりしていたら、おおっっ、なんだこりゃあああ!と思うものがありました。それがこれ↓です。
Raspberry Pi ZeroでMotionPNGTuberなAIコンパニオンを作る
うおおお、これは面白そう!最近一気見して自分のお気に入りキャラ一番に躍り出た某エルフの大魔法使いを、オレの専属秘書にして外に持ち出したい。これはやるしかねえ!というのが今回の記事でございます。
こんな実装ですよ
今回は構築手順を紹介する記事ではなく(設定範囲がかなり広範なコンポーネントに及ぶのでしんどいw)、実装手順のおおまかな流れと、自分でアレンジした点を主に紹介する記事とします。
私の実装も、基本はさきほどの本家リンク先の実装とほぼ同じです。しかしながら極力ローカルの環境だけで動作させることをコンセプトとしています。それぞれのコンポーネントで使っているものは以下の通りです。すべてWSL2上のubuntu24.04のコンテナで動作させました。
- STT(Speech to Text): Speeches AI
- LLM:llama.cpp(TurboQuant対応) モデルはレスポンスと賢さの両立を目指し、Gemma4-26B-A4Bにしました。
- TTS(Text to Speech): AivisSpeech Engine
追加として、話題のOpenClawを組み合わせ、SkillにはGoogleのPlaces APIを扱えるGoplacesを有効にしています。 また、図には入れていませんが、フリー〇ンとのチャットのやりとりは、やはり長期記憶として残してLLMを育成している感を味わいたいため、LangMemとベクトルデータベースのQdrantを組み合わせて、チャット内容の必要な部分を自動保存する仕組みも追加しました。

自分用にアレンジしよう
ここからが本題ですね。
LCDに表示されるキャラクターを用意する
これは、
- SDXLのwaiIllustriousのモデルでキャラクター画像を生成
- Wan2.2でフレームを101や105にしてループっぽい動画を生成
- ループ動画をインプットにして、ろてじんさんのMotionPNGTuberで口パク用Assetを作成
の流れで用意しました。補足すると、waiIllustriousは別途Loraを用意する必要はなしに、キャラクター名を指示すれば普通に生成されます。
Wan2.2はフレームを101以上程度にすると、1フレーム目に回帰しようとする傾向が強まるため、それを美味しく利用させてもらいました。
MotionPNGTuberは所持GPUのRTX5090だと初手からテコでもエラーで動かせなかったため、サブPCのRTX3060で動作させました。中古で3万の3060がウチでは大活躍。てか神グラボ。
キャラクターボイスを用意する
Youtubeでひろゆきボイスの動画をみていて、本物がしゃべっていると勘違いしていたころが俺にもありました。というわけでやり方を調査する前はこんなの本当にできるのか?できたとしてもかなり大変なんだろうなあ、と思っていましたが、2026年は普通にできてしまう恐ろしい時代でした。以下の流れで某エルフのボイスを発生させるためのモデルファイルを作成しました。
- オフィシャルのアニメの動画を某動画サイトで視聴し、2秒~12秒程度のセリフをしゃべっている箇所を適当にAudacityで録音、wavファイルにエクスポート。私は15ファイル(計2分くらい?)ほど用意しました。
- Style-Bert-VITS2でwavファイルを学習させ、safetensorsファイルを作成する。ここが一番のハマりどころかもしれません。ネット上で紹介されている手順ではSBV2がエラーでまくりで動かないため、このXのポストをヒントにがんばって動作させます。このポストがなかったら、マジでここで一生ハマったかも。
- AIVM Generatorに上記SBV2で出力された各種ファイルを読み込ませ、AivisSpeech Engineを使えるaivmx形式のファイルに変換
- AivisSpeech EngineのAPIでaivmxファイルを登録する。
aiavatar-piクライアントプログラムrun.pyのカスタマイズ
うえぞうさんのサンプルrun.pyをちょっと改造して、少し扱いやすくしました。私のrun.pyを貼っておきますので、参考にしてください。言うまでもなくIPアドレス(ポート番号は内緒)などは私の環境になっているので、そのままでは動きませんことよ。(例えば、家にいるときと外にいるときで、接続先のURLを変える処理を入れているなど)
- httpsでの動作に変更(aiavatar-piはhttp接続で何の問題もなさそうですが、aiavatarkitのほうにスマホのブラウザからアクセスするとhttpのせいでマイクが有効にならないため、httpsにした)
- 起動直後のマイクをミュート状態に変更
- ボタン長押しで液晶消灯(バックライトが消えるだけなので、バッテリー消費を抑える効果は限定的なんですが、常時表示されているのが嫌だったため実装)
# run.py
import sys
import ipget
import signal
import asyncio
import os
import logging
import time
from aiavatar_pi.device.whisplay import WhisplayMotionClient
def graceful_shutdown(signum, frame):
print("\n[INFO] 終了信号(SIGTERM)を受け取りました。後片付けを開始します...")
# ここに、ファイルやDBを閉じるなどの終了処理を書く
# 例: db.close(), file.close() など
print("[INFO] 後片付けが完了しました。終了します。")
sys.exit(0)
# SIGTERM(systemctl stop が送る信号)をキャッチするように登録
signal.signal(signal.SIGTERM, graceful_shutdown)
ip = ipget.ipget()
if '192.168.1.' in ip.ipaddr("wlan0"):
WEBSOCKET_URL = os.getenv("WEBSOCKET_URL", "wss://192.168.1.10:XXXXX/ws")
CHARACTER_URL = os.getenv("CHARACTER_URL", "https://192.168.1.10:XXXXX/static/motionpngtuber/frieren")
else:
WEBSOCKET_URL = os.getenv("WEBSOCKET_URL", "wss://www.shooting-bios.net:XXXXX/ws")
CHARACTER_URL = os.getenv("CHARACTER_URL", "https://www.shooting-bios.net:XXXXX/static/motionpngtuber/frieren")
logger = logging.getLogger()
logger.setLevel(logging.INFO)
log_format = logging.Formatter("[%(levelname)s] %(asctime)s : %(message)s")
streamHandler = logging.StreamHandler()
streamHandler.setFormatter(log_format)
logger.addHandler(streamHandler)
client = WhisplayMotionClient(
url=WEBSOCKET_URL,
character_url=CHARACTER_URL,
volume=90,
glow_config={
"solid": 3, # Border width (px)
"corner_radius": 42, # Rounded corner radius
"opacity": 1.0, # Opacity (0.0 to 1.0)
},
lipsync_config={
"cutoff_hz": 12.0, # Higher = faster response (default: 8.0)
"rms_queue_max": 2, # Lower = less latency (default: 3)
"peak_decay": 0.99, # Lower = faster volume tracking (default: 0.995)
},
)
# 起動時はミュート状態にする
client.mute()
LONG_PRESS_THRESHOLD = 1.5 # 長押し判定(秒)
_press_time = None
_display_on = True
@client.button.on_press
def on_press():
global _press_time
_press_time = time.monotonic()
@client.button.on_release
def on_release():
global _press_time, _display_on
if _press_time is None:
return
duration = time.monotonic() - _press_time
_press_time = None
if duration >= LONG_PRESS_THRESHOLD:
# 長押し → 液晶オン/オフ切り替え
_display_on = not _display_on
client.lcd.set_backlight(50 if _display_on else 0)
print(f"Display {'ON' if _display_on else 'OFF'}")
else:
# 短押し → マイクミュート切り替え
client.toggle_mute()
print(f"Mic {'muted' if client._user_muted else 'unmuted'}")
try:
asyncio.run(client.start())
except KeyboardInterrupt:
print("\nDisconnected.")
finally:
client.cleanup()
システムプロンプトの調整
ここも非常に重要なのですが、フリー〇ンの性格をできるだけ忠実に再現した発言をしてもらうため、システムプロンプト(この記事のパイプラインだとaiavatarkitで動作させるrun.pyに書く)に色々こうしてああしてと入れ込んでいきます。なお、ここでもAIを上手く利用できます。私の場合はGeminiに「ローカルLLMで葬送のフリー〇ンのフリー〇ンになりきってチャットをしてもらいたいと思っています。システムプロンプトにどういうふうに入れればいいでしょうか。フリー〇ンの性格や口癖は検索で調べてください。」と聞くことで、素晴らしいシステムプロンプトの叩き台が得られました。
そしてこんな感じに
というわけで以下の動画のように、いい感じにフリー〇ンと会話を楽しんでいる錯覚に陥ります。Google Places APIパワーにより、こんなやりとりが出来てしまいます。Google Places APIは月数千回は無料なので、この程度の利用ではどんなに使っても無料枠を超えることはないと思われます。まあ万が一に備え、クオータは設定しておきましょう。
ほかに実用性が多少ある使い方としてテスト的にやってみたこととしては、OpenClaw上のチャットでバスの時刻表を画像を添付してマークダウン形式に変換→ローカルwikiに登録、その内容をフリー〇ンに問い合わせると、直近のバス時刻をこたえてくれます。次のバスまで時間がないと、「急がないといけないね」なんて言ってくれるので、実際使うかどうかは別として面白さはありますね。
uezoさんの動画のようなレスポンスの速さは出ていないため、そのあたりは、コンポーネントの組み合わせ方の検討や各種パラメータのチューニングが必要だと思います。またZero2Wの負荷がそれなりに高く、Pisugar3のバッテリーが半日も持たないため、ffmpeg関連の負荷低減策も考えないといけないと思っています。
いずれ(今でも?)スマートウォッチなどに話しかけて同様のことができると思いますが、自分のお気に入りのキャラと声が動かせるのは、大きなアドバンテージであり、いろいろ機器を集めたり、ネットで調べたり、AIと一緒にコーディングしたりする価値は十分あるのではないかと思います。
