Electron・Go・whisper.cppで音声対話デスクトップマスコットエージェントを作った
デスクトップの片隅にいるキャラクターへ声をかけると、こちらを向いて返事をして、ときには秘書のように仕事も手伝ってくれる。
そんな仕組みは、以前なら少し手の込んだ「ロマン枠」のプロジェクトでした。
AIによって作業そのものは効率化されましたが、ちょっとした依頼のためにキーボードへ向かうことを、かえって手間に感じる場面も増えてきました。それなら、デスクトップにいるマスコットへ話しかけ、そのまま作業を頼めるようにしてみよう。
そんな実用性と遊び心の間にある発想から、音声対話デスクトップマスコットエージェントの開発を始めました。
今回作ったのは、macOSのデスクトップ上で動作する音声対話マスコットです。
主に次の機能を実装しました。
- 透過ウィンドウによるキャラクターの常駐表示
- モーションと物理演算
- グローバルホットキーを使ったプッシュトゥトーク
- whisper.cppによる日本語音声認識
- AIエージェントによる応答生成と操作実行
- GPT-SoVITSによる音声合成
- 再生中の音量に合わせた口パク
- 録音中、認識中、思考中、確認待ち、発話中などの状態表示
アプリは、表示と音声入出力を担当するElectronアプリと、各コンポーネントを結ぶGoオーケストレーターに分けています。
flowchart LR
User["ユーザー"] -->|"プッシュトゥトーク"| Electron["マスコット(Electron)"]
Electron -->|"WAV / WebSocket"| Go["Goオーケストレーター"]
Go -->|"音声認識"| Whisper["whisper.cpp"]
Whisper -->|"認識テキスト"| Go
Go -->|"プロンプト"| AI["Claude Code"]
AI -->|"応答テキスト"| Go
Go -->|"音声合成"| TTS["GPT-SoVITS"]
TTS -->|"WAV"| Go
Go -->|"合成音声"| Electron
Electron -->|"音声再生・口パク"| User
音声認識と音声合成はローカルで実行します。当初はGoオーケストレーターとTTSを別サーバーで動かす予定でしたが、手元のMacでも十分に動作したため、Electron、Go、whisper.cpp、GPT-SoVITSは同じMac上で動かす構成にしました。
Claude Codeや接続するMCPは外部サービスを利用する場合があるため、システム全体が完全にローカルで完結するわけではありません。
Electronでデスクトップマスコットを表示する
Section titled “Electronでデスクトップマスコットを表示する”Electron側は、背景を透過したウィンドウへキャラクターモデルを表示し、常に手前へ置くことでデスクトップマスコットとして動作させました。
物理演算、視線追従、カメラ調整などにも対応しています。各サーバーへの接続状態やログも確認できます。
右Optionキーで話しかける
Section titled “右Optionキーで話しかける”音声入力はプッシュトゥトーク方式にしました。右Optionキーを押すと録音を開始し、離すと録音を終了します。
ElectronのglobalShortcutでは、プッシュトゥトークに必要なキーアップを検知できません。そのため、グローバルなkeydownとkeyupを取得できるuiohook-napiを使用しました。
実際に使ってみると、毎回キーを押すことも少し面倒に感じます。今後は「Hey Siri」のように、呼びかけを検知して会話を始める機能も追加したいと考えています。
Goで会話の状態を管理する
Section titled “Goで会話の状態を管理する”Goオーケストレーターは、Electronとwhisper.cpp、AIエージェント、GPT-SoVITSの間を中継します。
ただし、単にHTTPリクエストを中継するだけではありません。音声対話では、録音、認識、応答生成、音声合成といった処理が順番に進みます。そこで、会話の進行を状態機械で管理しています。
stateDiagram-v2
[*] --> Idle
Idle --> Recording: PTT押下
Recording --> Transcribing: PTTを離す
Transcribing --> Thinking: 音声認識完了
Thinking --> Speaking: 通常応答
Thinking --> ConfirmPending: 確認が必要な操作
ConfirmPending --> Thinking: 実行を許可
ConfirmPending --> Idle: キャンセル
Speaking --> Idle: 音声送信完了
各状態はWebSocket経由でElectron側へ送り、マスコット上のバッジに表示しています。

whisper.cppで音声を文字起こしする
Section titled “whisper.cppで音声を文字起こしする”Electron側では、マイクから取得した音声を16bit PCMのWAVに変換し、WebSocketのバイナリフレームとしてGoへ送信します。
Goは受信したWAVをローカルで動かしているwhisper.cppサーバーの/inferenceへ送信し、日本語の認識結果を受け取ります。当初は誤認識が多いと思っていましたが、話者の滑舌が致命的に悪いのが原因でした。
AIで応答を生成する
Section titled “AIで応答を生成する”文字起こししたテキストは、GoからClaude Code CLIへ渡します。初回実行時に取得したセッションを保持し、次の発話からは--resumeを使って会話を継続します。
必要に応じてローカルのファイル操作やMCPなどを実行します。キャラクターの口調は、Claude Codeへ追加するシステムプロンプトで指定しています。実行結果はJSON形式で受け取り、その中から応答テキスト、セッションID、権限拒否の情報を取り出します。
GPT-SoVITSを呼び出す
Section titled “GPT-SoVITSを呼び出す”音声合成にはGPT-SoVITSを使用しました。当初はGradioのWeb UI経由で呼び出すことも検討しましたが、最終的にはPythonのローカルサーバーからGPT-SoVITSの推論処理を直接呼び出しています。
Goからは、応答テキストをPOST /synthesizeへ送るだけでWAVを受け取れる構成です。
音声モデルには、合計約20分の音声データを使いました。Hugging Faceを利用して検証していましたが、CPU環境でも数秒程度で生成できたため、最終的にはローカルで推論する構成に変更しました。生成音声も問題なく聞き取れる品質になりました。
マスコットで応答する
Section titled “マスコットで応答する”Goから受信したWAVは、Electron側のAudioContextで再生します。再生中はAnalyserNodeで音量を取得し、その値をモデルへ反映することで、簡易的な口パクを行っています。
エージェントの操作に確認フローを入れる
Section titled “エージェントの操作に確認フローを入れる”ローカル操作を許可する場合、会話から直接すべての操作を実行できる状態にするのは危険です。
通常時に使用できるツールとコマンドを制限し、削除などの操作が必要になった場合は、状態をConfirmPendingへ移してユーザーへ確認します。
- 右Optionキーを押す:実行を許可
- Escapeキーを押す:キャンセル
- 一定時間応答しない:タイムアウト
実行を許可した場合だけ、必要なコマンドを一時的に許可して同じセッションを再開します。また、Claude Code側には別途PolicyApprovalGateを設定し、音声の誤認識による意図しない操作を抑制しています。
実際の対話と処理時間
Section titled “実際の対話と処理時間”
MCP経由で予定を確認したところ、処理時間はおおよそ次のとおりでした。
- 音声認識:約1秒
- 応答生成と操作:約10秒
- 音声生成:約1秒
環境や依頼内容によって処理時間は変動します。
- 話者である私の滑舌の悪さによって、音声認識に失敗することがある
- 誤認識した内容がAIエージェントへ送信された後は、処理を途中で止められない
- 認識結果から意図を推測して回答してくれる場合もあるが、誤操作を防ぐには割り込みとキャンセルが必要
今後やりたいこと
Section titled “今後やりたいこと”- 発話中の割り込みとキャンセルに対応する
- 待ち時間を短縮する、または待ち時間に短い会話を挟む
- 応答内容に合わせて表情やモーションを切り替える
- 英語モデルを作成して英会話を試す
今回の開発では、デスクトップマスコットを形にするだけでなく、音声認識(STT)や音声合成(TTS)、音声モデルの学習まで一通り試すことができました。
これまでは個別の技術として見ていたものも、実際に一つの音声対話へ組み込んでみると、認識精度や生成時間、状態管理など、それぞれの処理が会話全体の体験に影響することが分かりました。特に、学習用データの準備からモデルの学習、ローカルでの音声生成まで実際に試せたことは、良い経験になりました。
まだ応答速度や発話中の割り込みなど改善したい部分はありますが、「ロマン枠」として始めたプロジェクトを通して、音声AIを構成する技術を実践的に学べたのは大きな収穫でした。
今後も少しずつ改良しながら、より自然に会話できるデスクトップマスコットへ育てていきたいと思います。