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?

Claude Code に手伝ってもらいながら、音声で話せるデスクトップ常駐キャラを作ってみた

0
Last updated at Posted at 2026-10-28

はじめに

Ubuntu のデスクトップに常駐して、話しかけると PC の操作まで手伝ってくれるキャラクター「pcbot」を、AI(Claude Code)に手伝ってもらいながら作ってみました。

自分で決めたのは「何を作りたいか」と一部の方針で、コードの多くは Claude Code と相談しながら書いてもらっています。そのため、この記事は「完成品の解説」というより、作ってみて自分が理解したことのまとめです。間違っているところがあれば教えてもらえるとうれしいです。

  • 画面の右下にキャラがいて、マウスカーソルの方を向きます
  • ショートカットキーで呼び出して、チャットや音声で話しかけられます
  • 中身は Claude Code です。ファイルの読み書きやコマンド実行に加えて、アプリの起動やウィンドウの切り替えなどの PC 操作もできます
  • 危ない操作の前には、チャット窓に「許可 / 拒否」のボタンが出ます

画像:
states.png
(左から 待機 / 聞いている / 考え中 / しゃべる)

この記事では、作ってみたものの紹介と、主な仕組み(Claude Code の呼び出し方、MCP で許可確認を自前の GUI に出す方法、音声認識、1枚の画像を立体っぽく動かす方法)について、調べて分かったことをまとめます。

動作環境

項目 内容
OS Ubuntu 24.04(X11 セッション)
Python 3.12
GUI PySide6 6.11
AI Claude Code(claude -p を子プロセスとして呼ぶ)
音声認識 faster-whisper(small モデル、CPU)
読み上げ VOICEVOX 互換の API

GPU は使っていません。手元のノート PC の CPU(Core i7-1165G7)で動かしています。

できること

デスクトップ常駐キャラ

枠なし・背景透過・常に最前面のウィンドウにキャラを描いています。

  • 見た目: 標準は QPainter で描いたロボットです。好きな画像1枚を選ぶと、その画像のキャラに切り替わります
  • 視線: マウスカーソルの方を向き、考え中は右上を見ます
  • 状態: 待機 / 聞いている / 考え中 / しゃべる の4つで、吹き出しに返答の要約が出ます
  • 操作: 左ドラッグで移動、ダブルクリックでチャット、右クリックでメニューです

画像:
look_demo.gif
(カーソルを追いかける様子)

チャットと PC 操作

チャット窓から話しかけると、裏で Claude Code が動きます。Claude のサブスクリプションの範囲で使えるので、API キーは不要です。

Claude Code の組み込みの道具(Read / Grep / Glob / WebSearch / WebFetch / Bash / Edit / Write)に加えて、PC 操作の道具を MCP サーバーで渡すようにしてみました。

種類 道具 確認
読む 作業フォルダ(~/pcbot_work)内の Read / Grep / Glob なし
読む 作業フォルダの外の Read / Grep / Glob あり
調べる WebSearch / WebFetch なし
書く・実行 Write / Edit / Bash あり(Bash は毎回)
PC 操作 アプリ検索、ウィンドウ一覧と切り替え、通知、キャラの一言 なし
PC 操作 URL やファイルを開く、アプリ起動、スクリーンショット、文字入力、キー、クリック、クリップボード あり

音声で話しかける

Ctrl+Alt+V を押して話しかけると、話し終わったところで自動で文字にして送信します。返答は文ごとに読み上げます。

  • コードブロックは読まずに「コードは画面を見てください」と言います
  • URL は「リンク」と読みます
  • 長い返答は約300文字で読み上げを止めて「続きは画面を見てください」と言います

ショートカットキー

GNOME のカスタムショートカットに登録しているので、どのアプリを使っている最中でも呼び出せます。

キー 動作
Ctrl+Alt+Space チャット窓を開く / 閉じる
Ctrl+Alt+V 話しかける(もう一度押すと聞き取り終了)

全体の構成

プロセスは3つに分かれています。pcbot 本体、pcbot が起動する claude -p、claude が起動する MCP サーバーです。

道具を実行するかどうかを決めるのは Claude Code(図の「許可チェック」)です。LLM は「この道具をこの引数で使いたい」と書くだけです。MCP サーバーは、通った依頼を受けて実際に作業する役で、Claude Code にない PC 操作の道具を後から足すための差し込み口です。

ファイル 役割
main.py 起動、トレイ、二重起動防止
avatar.py キャラのウィンドウ、ロボットの描画、視線の追従
image_avatar.py 画像キャラの 2.5D 描画
chat.py チャット、Claude、音声、キャラの状態をつなぐ
chat_window.py チャット窓
claude_client.py Claude Code の呼び出しと stream-json の解析
mcp_server.py PC 操作の道具と許可確認(標準ライブラリだけの MCP サーバー)
control.py / ctl.py 外部から操作するためのローカル HTTP サーバーとコマンド
voice.py 音声認識と読み上げ

全部で約2,500行の Python です。

処理の流れ

文字で話しかけてから返事が出るまで

道具を使うときの判定

LLM が道具を使いたいと言ってから、実行されるまでの判定です。判定するのは Claude Code と pcbot で、MCP サーバーは判定しません。

許可確認でのスレッドのやり取り

問い合わせを受けた HTTP スレッドは、ユーザーが答えるまで threading.Event で待ちます。止まるのは HTTP スレッドだけなので、GUI は固まらず、ボタンを押せます。

  • 「停止」を押すと deny_all() が、待っている Event をすべて拒否にして起こします
  • 5分たっても答えがなければ ev.wait(timeout) が戻り、拒否として返します

声で話しかけるとき

仕組み

Claude Code を子プロセスとして呼ぶ

Claude Code を -p(非対話モード)で起動し、--output-format stream-json で1行ずつ届く JSON を読んでいます。Qt の QProcess を使うので、GUI が固まりません。

args = ["-p", message, "--output-format", "stream-json", "--verbose",
        "--include-partial-messages",              # 文字を少しずつ受け取る
        "--tools", "Read,Grep,Glob,WebSearch,WebFetch,Bash,Edit,Write",
        "--mcp-config", mcp_config_json,           # PC 操作の道具 + 許可確認役
        "--strict-mcp-config",                     # 個人の MCP 設定は読み込まない
        "--permission-mode", "default",
        "--permission-prompt-tool", "mcp__pcbot__permission_prompt",
        "--append-system-prompt", system_prompt]
if session_id:
    args += ["--resume", session_id]               # 会話の続き

動かしてみてつまずいたところや、入れてみた工夫は次のとおりです。

  • 会話の続き: JSON に含まれる session_id を覚えておき、次の質問で --resume に渡しています
  • 少しずつ表示: --include-partial-messages で届く text_delta をチャット窓と読み上げに流しています。差分が来ない環境向けに、まとめて届いた文章も処理しています
  • 標準入力を閉じる: 開いたままだと、claude が入力を待って約3秒止まりました。起動直後に closeWriteChannel() で閉じています
  • 古い Claude Code への対応: 知らないオプションのエラーが出たら、そのオプションを外して一度だけやり直します
  • 作業フォルダを分ける: Claude は ~/pcbot_work で動かし、この中の読み取りだけを確認なしにしています

許可の確認を自分の GUI に出す

Claude Code には --permission-prompt-tool というオプションがあります。道具を使う前に確認が必要になると、指定した MCP の道具に「使ってよいか」を問い合わせてくれます。これを使うと確認をチャット窓のボタンで出せると知り、試してみました。

流れは次のとおりです。

  1. Claude Code が、pcbot の MCP サーバーの permission_prompt を呼ぶ
  2. MCP サーバーが、pcbot 本体のローカル HTTP(POST /permission)に問い合わせる
  3. pcbot がチャット窓に「何をしようとしているか」と [拒否] [この会話中は許可] [許可] を出す
  4. ユーザーの答えを MCP サーバー経由で Claude Code に返す
def permission_prompt(tool_name: str, input: dict, tool_use_id: str = "") -> str:
    try:
        res = _post("/permission", {"tool_name": tool_name, "input": input}, timeout=330)
    except Exception as e:
        res = {"behavior": "deny", "message": f"pcbot に確認できませんでした: {e}"}
    if res.get("behavior") == "allow":
        return json.dumps({"behavior": "allow", "updatedInput": input})
    return json.dumps({"behavior": "deny", "message": res.get("message", "ユーザーが拒否しました。")})

安全のために、次のようにしてみました。

  • フォーカスは [拒否] に置いて、Enter の押し間違いで許可しないようにしています
  • Bash は「この会話中は許可」を出さず、毎回確認します
  • 5分間答えがなければ拒否扱いにします。「停止」を押すと、確認待ちもすべて拒否します

HTTP サーバーのスレッドから GUI に確認を出す部分は、Qt のシグナルでメインスレッドに渡し、threading.Event で答えを待っています。

MCP サーバーを標準ライブラリだけで書く

MCP サーバーは、Claude Code が子プロセスとして起動し、標準入出力で JSON-RPC をやり取りします。必要なのは initialize、tools/list、tools/call くらいだったので、SDK を使わず約300行で書いてもらいました。

for line in sys.stdin:
    msg = json.loads(line)
    method, id_ = msg.get("method"), msg.get("id")
    if id_ is None:
        continue  # 通知には返事しない
    if method == "initialize":
        _reply(id_, {"protocolVersion": msg["params"].get("protocolVersion", "2025-06-18"),
                     "capabilities": {"tools": {}},
                     "serverInfo": {"name": "pcbot", "version": "0.1"}})
    elif method == "tools/list":
        _reply(id_, {"tools": [{"name": n, "description": d, "inputSchema": s}
                               for n, (d, s, _) in TOOLS.items()]})
    elif method == "tools/call":
        p = msg.get("params", {})
        _reply(id_, _call(p.get("name", ""), p.get("arguments") or {}))

PC 操作の中身は、Linux の定番コマンドを呼んでいるだけです。中身は意外と単純でした。

道具 使っているコマンド
URL・ファイルを開く xdg-open
アプリ起動 .desktop ファイルを検索して gtk-launch
ウィンドウ一覧・切り替え wmctrl
文字入力・キー・クリック xdotool
スクリーンショット ImageMagick の import(縮小して JPEG で返す)
クリップボード xclip
通知 notify-send

スクリーンショットは MCP の画像として返すので、Claude が画面を見てクリックする座標を決められます。縮小した場合は、元の画面座標への倍率も一緒に返しています。

ウィンドウ操作、文字入力、クリック、スクリーンショットは X11 セッションが必要です。Wayland ではセキュリティ上、他のアプリへの入力や画面の取得が制限されています。

音声認識(押して話す方式)

録音から文字起こしまでの流れは次のとおりです。

  1. 録音: sounddevice でマイクから 30ms ごとに音を受け取り、メモリに貯めます(ファイルには保存しません)
  2. 話し終わりの判定: 最初の 0.3秒で周囲の雑音の大きさを測り、しきい値を max(雑音 × 3, 0.012) にします。しきい値以下が 0.9秒続いたら話し終わりとみなします
  3. 変換: マイクの周波数(48kHz など)から、Whisper 用の 16kHz に線形補間で変換します
  4. 文字起こし: numpy の配列のまま faster-whisper に渡します。vad_filter=True で Whisper 側でも無音を除き、日本語に寄せるため initial_prompt を入れています
model = WhisperModel("small", device="cpu", compute_type="int8")
segments, _ = model.transcribe(audio, language="ja", beam_size=1, vad_filter=True,
                               initial_prompt="こんにちは。今日はいい天気ですね。")

モデルはアプリの起動時に裏で読み込んでおき、最初に話しかけたときに待たないようにしています。

しきい値の方式は単純なので、周りが騒がしいと「話し終わった」と判定されにくく、録音が続くことがあります。今はショートカットキーをもう一度押して確定する運用にしています。

読み上げ

返答を 。!? や改行で文に区切り、1文ずつ音声にして順番に再生しています。

  • 音声を作るスレッドと再生するスレッドを分けて、次の文を先に作っておきます
  • 「停止」や新しい質問のときは世代番号を増やし、古い文を捨てます
  • マークダウンの記号や絵文字は読む前に取り除きます

読み上げエンジンは VOICEVOX の API(/audio_query → /synthesis)で呼んでいるので、同じ API のエンジンなら設定を変えるだけで使えます。

1枚の画像を立体っぽく動かす(2.5D)

好きな画像1枚から、首振りに合わせて立体的に見える動きを作れないか試してみました。すべて numpy と OpenCV の CPU 処理で、300px 程度なら1フレーム数ミリ秒です。

  1. 厚みを作る: 画像のシルエットに距離変換をかけ、縁から遠いほど高くなるように盛り上げます。断面が円になる式(sqrt(2dr - d²))で、風船をふくらませるイメージです
  2. 変形する: 首振り(yaw)とうなずき(pitch)に合わせて、厚い部分ほど大きくずらします(cv2.remap)。実際に 3D で回したときの視差と同じ考え方です
  3. 陰影をつける: 厚みから面の向き(法線)を求め、回転後の向きと光の向きから明るさを計算します
  4. 目を動かす: 鮮やかな色の丸い領域を黒目とみなして切り出し、視線の方向へ動かします。元の場所は周りの白目の色で埋めます。白目の上からまぶたの色を下ろして、まばたきもさせています
d = cv2.distanceTransform(solid, cv2.DIST_L2, 5)
r = max(float(d.max()), 1.0)
height = np.sqrt(np.clip(2 * d * r - d * d, 0, None))   # 断面が円になる盛り上げ
...
map_x = (grid_x - cx) / (0.85 + 0.15 * cos(yaw)) + cx - height * sin(yaw)
map_y = (grid_y - cy) / (0.9 + 0.1 * cos(pitch)) + cy - height * sin(pitch)
col = cv2.remap(rgba, map_x, map_y, cv2.INTER_LINEAR)

試した範囲では、背景が透過した PNG がうまくいきました。背景がある画像は四隅から塗りつぶして背景を推定します。全身より、顔から上半身くらいの正面向きの絵の方が自然に見えました。

画像:
riri_look.gif
(画像キャラが視線を追う様子)

外部からキャラを動かす

ローカル HTTP サーバーで、別のプログラムからキャラの状態を変えられるようにしてみました。将来、ロボットなど別のシステムとつなげられたらと思って用意した窓口です。

.venv/bin/python -m pcbot.ctl say "了解です、ブラウザを開きますね"
curl -X POST localhost:8765/avatar -d '{"state":"speaking","text":"こんにちは","duration":3}'

ショートカットキーも、この HTTP サーバーに chat や listen を送っているだけです。

設計で決めたこと

自分で決めたことと、Claude の提案をそのまま採用したことが混ざっているので、分けて書きます。

Claude Code を CLI で呼ぶ

ここは自分で決めました。API を直接呼ぶのではなく、claude -p を子プロセスとして起動しています。

  • API キーが要らない: Claude のサブスクリプションの範囲で使えます
  • 道具と許可の仕組みが最初からある: ファイルの読み書きやコマンド実行の道具、確認を出すかどうかの判定を、自分で作らずに済みます
  • MCP で道具を足せる: PC 操作は MCP サーバーとして足すだけで済みます

そのかわり、毎回プロセスを起動するので、API を直接呼ぶより返事が始まるまで少し時間がかかります。

音声認識は faster-whisper

ここも自分で決めました。

  • PC の中で完結する: 声をネットに送りません
  • CPU で動く: int8 の small モデルなら、ノート PC でも実用的な速さです

1枚の画像を 2.5D で動かす

普段使っているアイコンを、そのままキャラにしたかったからです。キャラごとに 3D モデルや差分の絵を用意しなくても、画像1枚で首振りやまばたきができます。

GUI は PySide6

GUI のライブラリは、自分で比べて選んだのではなく、Claude に提案されたものをそのまま採用しました。作ってみて分かった利点と弱点は次のとおりです。

  • 利点: 枠なし・背景透過・常に最前面のウィンドウを素直に作れます。QProcess で claude を待っても GUI が固まらず、シグナルで別スレッドから GUI に安全に知らせられます
  • 弱点: パッケージが大きく、OpenCV の Qt とぶつかったり、特定のバージョンで落ちたりしました(「ハマったこと」を参照)

MCP サーバーと本体の間はローカル HTTP

MCP サーバーは claude が起動する別のプロセスなので、pcbot のチャット窓に直接ボタンを出せません。そこで、MCP サーバーから pcbot 本体へ HTTP で問い合わせています。

ファイルや Unix ソケットでもやり取りはできますが、HTTP にしたのは次の理由からです。

  • 標準ライブラリだけで書ける: サーバーは http.server、送る側は urllib です
  • curl で試せる: GUI を通さずに動作を確認できます
  • 外部連携にも使い回せる: 許可確認のために作った窓口が、そのまま /avatar などの外部から操作する窓口になりました

弱点もあります。

  • 認証がない: 127.0.0.1 に限っているので外からは届きませんが、同じ PC のプログラムなら誰でも叩けます
  • ポートがぶつかることがある: 8765 番をほかのアプリが使っていると起動できません

ハマったこと

OpenCV の Qt と PySide6 がぶつかる

GUI 版の opencv-python は独自の Qt を同梱していて、PySide6 と衝突します。画像処理しか使わないので opencv-python-headless にしました。

PySide6 6.12.0 で数秒後に落ちる

Python 3.10 / 3.11 で PySide6 6.12.0 を使うと、QPainter の描画中に None の参照カウントが壊れて、数秒で落ちました。requirements.txt で PySide6>=6.5,<6.12 に固定しています。

Ubuntu 24.04 に上げたら PySide6 が見つからなくなった

22.04 から 24.04 に上げたところ、ModuleNotFoundError: No module named 'PySide6' で起動しなくなりました。

原因は Qt ではなく、仮想環境でした。

  • 仮想環境の python は、/usr/bin/python3 へのリンクです
  • OS の更新でリンク先が Python 3.10 から 3.12 に変わりました
  • パッケージは lib/python3.10/site-packages にしか入っていないので、3.12 からは見えなくなりました

rm -rf .venv で作り直すだけで直りました。OS を上げたら、仮想環境の作り直しを最初に疑うのがよさそうです。

Wayland ではカーソル位置や他のアプリの操作が取れない

Wayland ではセキュリティ上、他のアプリの上にあるカーソル位置を取得できず、キャラの視線が止まります。PC 操作の道具も動かないので、ログイン画面で「Ubuntu on Xorg」を選んで使っています。

おわりに

Claude Code を -p で呼び、--permission-prompt-tool と MCP サーバーを組み合わせることで、Claude Code を中身にした自分用のアシスタントを、ひとまず形にすることができました。許可確認を自分の GUI に出せるので、PC 操作のような危ない道具も、少し安心して試せるようになりました。

AI に手伝ってもらうと、自分ひとりでは手が届かなかったものも動くところまで持っていけます。一方で、動いたあとに「なぜこう書いたのか」を自分で説明できるようにするのが大事だと感じました。この記事も、その確認を兼ねて書いています。

今後やりたいことは次のとおりです。

  • 話し終わりの判定を改良する(単発のノイズで無音のカウントを戻さない、など)
  • 読み上げエンジンの見直し(AivisSpeech、Open JTalk などを検討中)
  • ロボットなど、外部のシステムとの連携

参考

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?