概要
眠れない夜更けにClaude Code CLIの入出力を音声で中継するmacOSアシスタントのデモを作ってみた。whisper.cppで音声をテキストに変換しGoogle Cloud TTSで応答を読み上げる程度だが作る過程が楽しかった。
きっかけ
夜中の2時頃だった。寝ようとしたが眠れない。寝返りを打った末に諦めてノートパソコンを開いた。開発者にとって眠れない夜はすなわちサイドプロジェクトの始まりだ。
最近Claude Codeをほぼ一日中使っている。コード検索からリファクタリングやデバッグまでターミナルでテキストをやり取りする方式だがふと思った。ここにマイクで話しかけてスピーカーで返事を聞けたらどうだろう?
実用的かと聞かれれば正直に言うとそうではない。コードは目で見るものだしターミナル出力を音声で聞くのは非効率的だ。しかし「今のブランチは何?」「このファイルを開いて」のような簡単なコマンドや「さっきの変更を要約して」のような確認作業なら音声の方が楽かもしれないと思った。正直に言えば実用性よりただ作ってみたかった。深夜にこんなものを一人で作りながらニヤニヤしているのがこの仕事の醍醐味ではないだろうか。
布団の中で大まかなアーキテクチャを頭の中で描いた。STTはWhisper、TTSはGoogle Cloud、Claude CodeはCLI subprocessで繋げばいい。パーツがわりときれいにはまりそうだったのですぐに作業に取りかかった。
設計
1. 全体構造
Xcodeプロジェクトなしの Swift Package Managerのみで構成したmacOSネイティブアプリだ。SwiftUIベースでmacOS 15以上をターゲットとする。外部Swiftパッケージ依存はない。STTはwhisper.cppバイナリをsubprocessで実行しTTSはGoogle Cloud REST APIを呼び出すのでSPM依存が不要だった。深夜に依存性管理までしたくなかったというのも理由の一つだ。
アプリは二つのインターフェースで構成される。メニューバーをクリックすると使用量ダッシュボードがポップオーバーで表示されグローバルショートカット(⌥⌘V)を押すとフローティングアシスタントパネルが開く。ダッシュボードは以前作ったClaude使用量モニターをそのまま持ってきたものでアシスタントパネルが今回新しく追加した部分だ。
メニューバークリック時に表示される使用量ダッシュボード
サービス層はProtocolで抽象化した。音声認識(SpeechRecognizing)、音声合成(SpeechSynthesizing)、Claude Code実行(ClaudeCodeExecuting)、ショートカット検知(HotkeyListening)、権限管理(PermissionChecking)に対してそれぞれプロトコルを定義し具体的な実装を注入する方式だ。例えばSpeechRecognizingプロトコルはstartListening()とstopListening()だけを定義し具体的なWhisper連携はWhisperServiceが担当する。後で別のSTTエンジンに入れ替える際に修正範囲を狭めようという意図だ。
ServiceContainerが全ての依存関係を組み立てる。TTS APIキーや音声設定はUserDefaultsから読み込みServiceContainer初期化時に各サービスに注入される。AppDelegateがこのコンテナを通じてViewModelを構成しViewModelがViewに接続される構造だ。深夜のデモプロジェクトにしては構成が大げさかもしれないが何かを変えたくなったときに一箇所だけ触れば済むのは楽だった。
2. ステートマシン
アシスタントの動作は五つの状態を遷移する有限ステートマシンで管理する。
待機(idle)状態でショートカットを押すと聞き取り(listening)状態に遷移する。ユーザーが話し終えると処理(processing)状態に移りClaude Codeが応答を生成する。応答が準備できたら発話(speaking)状態でTTS音声を再生し再び待機状態に戻る。どの状態でもエラーが発生するとエラー(error)状態に遷移しリトライまたはキャンセルで待機状態に復帰できる。
最初はステートマシンなしでBoolフラグ数個で管理しようとした。isListening、isProcessing、isSpeakingといった具合に。しかし作業を進めるうちに「録音中にまた録音リクエストが来たら?」「TTS再生中に新しい応答が来たら?」のようなエッジケースが次々と現れて結局ステートマシンに切り替えた。状態遷移ロジックは現在の状態とイベントの組み合わせで次の状態を決定し許可されていない遷移はnilを返して無視される。おかげで非同期フローが複雑になっても状態の追跡がしやすい。
一つハマったのはprocessingからspeakingへの遷移だ。Claude Code応答の受信とTTS合成が同じTask内で連続して行われるが状態変更のたびにactiveTaskをcancelするロジックを入れていたためTTS合成まで一緒にキャンセルされる問題が発生した。夜中の3時に「なぜ音が出ないのか」を延々とデバッグしてこの遷移だけ例外的に既存のTaskを維持するよう処理して解決した。
待機状態 — ショートカットを押すと音声入力が始まる
3. 音声入力 — whisper.cpp
STTにはwhisper.cppを使用する。最初はAppleのSpeechフレームワークを試したが英語では問題ないものの韓国語の短い文で認識率がいまいちだった。「現在のブランチを教えて」が全く別の文として認識されるのを見て即座にWhisperに乗り換えた。large-v3-turboモデルはローカルで動作しながらも韓国語認識精度は悪くなかった。ネットワーク不要で動くのはおまけだ。
操作方式はPush-to-Talkだ。⌥⌘Vを押し続けると録音が始まり離すと録音が終わる。最初はNSEventのグローバルモニターでショートカット検知をしたがmodifierキーの組み合わせのkeyDown/keyUpをPush-to-Talk方式で正しくキャプチャできなかった。CGEvent tapはより低レベルだがイベントを消費(nil返却)できるのでショートカットを押している間他のアプリにキー入力が伝わらないようにできる。
録音されたオーディオはAVAudioEngineのinputNodeにtapを設置してキャプチャする。Whisperがmono入力を要求するので1チャンネルフォーマットで受け取る。設定からマイク選択ができるようにしたがCoreAudioのAudioUnitプロパティを直接設定して入力デバイスを変更している。
キャプチャされたCAFファイルはmacOS内蔵ユーティリティのafconvertで16kHz 16-bit mono WAVに変換される。Whisperがこのフォーマットを要求するためだ。変換後はffmpegで3秒以上続く無音区間を除去する前処理を経る。
Whisperモデルにはhallucinationという厄介な問題がある。何も話していないのに無音入力から幻聴のように「ありがとうございます」「チャンネル登録お願いします」のような文章を生成してしまうのだ。YouTube字幕の学習データの影響と思われるが初めて見た時は少しぞっとした。これを抑えるために安全装置を何重にも積み重ねる必要があった。録音時間が0.8秒未満またはピークオーディオレベルが閾値以下の場合は転写自体をスキップする。無音除去後のファイルサイズが10KB未満であればこれもスキップする。whisper.cpp実行時には-nt(タイムスタンプなし)と-mc 0(コンテキスト蓄積なし)フラグでモデルが自身の以前の出力に引きずられないようにした。全部適用してようやく安定して動作するようになった。
音声入力中 — アバターが耳を傾けている
4. 音声出力 — Google Cloud TTS
TTSにはGoogle Cloud Text-to-SpeechのChirp 3 HDモデルを使用する。Appleの内蔵TTSも試したが韓国語の音声があまりに機械的だった。Chirp 3 HDはGoogleが2025年にリリースしたモデルで抑揚や息遣いがなかなか自然だ。初めて聞いた時「お、これなら悪くないな」と思った。料金ページを確認したところ毎月0〜100万文字まで無料枠が提供されておりデモプロジェクトには気軽に使えた。
設定画面で音声を選択しプレビューできるようにした。Chirp 3 HDは男女の音声を含む多様なプリセットを提供しておりそれぞれ固有の名前(Alnilam、Charonなど)が付いている。API呼び出し時にはLINEAR16(非圧縮PCM)エンコーディングに48kHzサンプルレートを指定してできるだけ良い音質で受け取る。
Claude Codeの応答からTTS用テキストを分離する部分が地味に面白かった。Claudeの応答にはコードブロックやファイルパスやマークダウン文法がたくさん混在しておりそのまま読み上げるのは困る。システムプロンプトに応答の最後に[TTS]...[/TTS]マーカーを挿入するよう指示しマーカー内には核心的な内容だけを自然な口語体で書くようにした。コンマを最小限にし数字は発音通りに書き特殊記号を使わないなどのルールも決めたがこのルールを一つ一つ調整するのにけっこう手がかかった。
試行錯誤もあった。最初はTTSオーディオをそのまま再生したところ最初の約0.5秒がカットされて聞こえた。「おはようございます」が「ございます」に聞こえるのだ。macOSのオーディオハードウェアの初期化に時間がかかることが原因だった。解決方法はWAVデータの先頭に300ミリ秒の無音を挿入することだったが思ったより簡単ではなかった。WAVファイルにはRIFFヘッダーがありヘッダー内に全体ファイルサイズとデータチャンクサイズが記録されている。無音を挿入するとこれらの値をバイト単位で直接修正する必要がある。Raw PCM応答の場合は単純に先頭に0バイトを付け加えればよいがWAVヘッダーがある場合はoffset 4(RIFFサイズ)とoffset 40(データチャンクサイズ)を更新しなければならない。実はこのバイトオフセットの計算は自分でやったわけではなくClaude Codeがやってくれた。夜中の4時に横で「音が出ない」「最初が切れる」とぼやいていたら勝手にヘッダーを直してくれた。音声が正常に再生された瞬間の達成感はなかなかのものだった。
Google Cloud TTS設定 — 音声選択とプレビューに対応
5. アバター
アシスタントにアバターを入れたのは完全に遊び目的だ。機能的には全く必要ないが深夜作業の醍醐味はこんなくだらないことに時間を使えることではないだろうか。SwiftUIで描いた丸い顔にメガネをかけたキャラクターで状態に応じて表情が変わる。
表情はFaceExpressionという構造体で定義される。目の縦スケール(eyeScaleY)、瞳の位置(pupilOffsetX/Y)、眉の高さと角度(eyebrowOffsetY、eyebrowLeftAngle、eyebrowRightAngle)、口の開き具合(mouthOpen)、笑みの程度(mouthSmile)のようなパラメータの組み合わせだ。待機状態ではこれらのパラメータをランダムに組み合わせた14種類の表情を1.8秒間隔で交互に見せる。少し目を閉じたり片方の眉を上げたり視線を動かしたりといった微細な変化だ。
聞き取り状態では目が大きくなり(eyeScaleY: 1.15)耳に手を当てるジェスチャーが現れる。手の形は楕円形の手のひらの上に3つのカプセル状の指を乗せてシンプルに表現した。処理状態では瞳がsin/cos関数に従って円を描いて考える表現をしツールの活動に「検索」が含まれると瞳が左右に素早く往復する別のアニメーションが適用される。コード修正やファイル作成中は眉が寄り目が少し狭まる集中した表情になる。
発話状態では口が0.16秒間隔でランダムなサイズ(0〜0.6)で開閉する。口の形はMouthShapeというカスタムShapeで実装しopenAmountとsmileをAnimatableDataとして宣言してSwiftUIのspringアニメーションが自然に適用されるようにした。
ここでやめておけばよかったのだが深夜テンションというものがある。2.5〜5.5秒間隔でランダムにまばたきし(まぶたが0.07秒で閉じ0.12秒で開く)呼吸に合わせて体全体が0.985〜1.015の範囲で微かに拡縮するアニメーションまで入れた。誰も気にしないディテールだがこれをいじくっている時間がこのプロジェクトで一番楽しかった。
応答中 — アバターが口を動かしながらTTSテキストが一緒に表示される
6. Claude Code連携
核心はClaude Code CLIをsubprocessとして実行することだ。--printフラグで非対話モードで実行し--output-format stream-jsonでJSONストリーミングレスポンスを受け取る。--continueフラグで以前の会話を引き継げるのでコンテキストが維持される連続対話が可能だ。
JSONストリーミングレスポンスのパースが地味に楽しい作業だった。Claude Codeは1行に1つのJSONオブジェクトを出力しそのtypeフィールドがassistantでmessage.content配列の中にtextブロックとtool_useブロックが混在して返ってくる。tool_useブロックからツール名を抽出すると現在何をしているかがわかる。Bashなら「コマンド実行中…」Readなら「ファイル読み込み中…」Grepなら「コード検索中…」という具合にマッピングしておいた。
システムプロンプトには時間帯別の挨拶が変わるペルソナを設定した。深夜なら「遅い時間までお疲れ様です」朝なら「おはようございます」と始まり報告形式で簡潔に応答し過度な謝罪や説明はしないようにした。最初の会話でのみ挨拶しその後の連続会話では挨拶を繰り返さないのも自然さのためのディテールだ。深夜に「遅い時間までお疲れ様です」という挨拶を初めて聞いた時思わず一人で笑ってしまった。
ここでかなり時間を取られたバグが一つある。Claude Code CLIをsubprocessとして実行する際に親プロセスの環境変数をそのまま継承すると問題が生じる。CLAUDE_CODE_ENTRYPOINTとCLAUDECODE環境変数が設定されているとClaude Codeが自身が別のClaude Codeインスタンス内で実行されていると判断して動作が変わる。「なぜターミナルでは動くのにアプリからだと動かないのか」を延々と繰り返した末に環境変数をダンプしてようやく原因を突き止めた。これらの変数をプロセス環境から明示的に削除して独立したセッションとして実行する必要がある。
7. チャットUI
アシスタントパネルはNSPanelを使ったフローティングウィンドウだ。NSPanelの.nonactivatingPanelスタイルを使うとパネルが表示されても現在作業中のアプリのフォーカスを奪わない。コードを書きながら横にアシスタントを表示しておくシナリオに適している。.canJoinAllSpacesと.fullScreenAuxiliaryで全てのデスクトップスペースとフルスクリーンでもアクセスできhidesOnDeactivateをfalseにして他のアプリに切り替えてもパネルが消えないようにした。
チャットメッセージはカカオトークスタイルの吹き出しで表示される。ユーザーメッセージは右側の青い吹き出しに白文字でアシスタントメッセージは左側の半透明の吹き出しで表示される。UnevenRoundedRectangleを使って吹き出しのしっぽ側の角だけ丸みを減らして自然な形にした。
テキスト入力もサポートしている。音声だけで全てをこなせるが静かな環境や長いテキストを伝えたい場合は下部のテキストフィールドから直接入力できる。画像添付も可能でNSOpenPanelで画像を選択するとメッセージにパスが含まれてClaude Codeに渡される。
処理中にはClaude Codeが現在どのツールを使用しているかリアルタイムで表示されるインジケーターが現れる。ツールの種類によってアイコンが変わり検索なら虫眼鏡、ファイル読み込みならドキュメント、コード修正なら鉛筆、Web検索なら地球儀アイコンが表示される。
実際の会話 — フォルダ移動や状態確認などのコマンドを音声で処理する
振り返り
夜眠れなくて始めたデモプロジェクトにしては思ったより使えるものになった楽しく遊べた。「Claude Codeに音声を載せられるか?」という問いへの答え程度にはなった。
実際に使ってみると「今の作業フォルダどこ?」と音声で聞いて「現在の作業ディレクトリはデスクトップのリポジトリフォルダです」とスピーカーから聞こえる体験がなかなか新鮮だ。「このリポジトリのワーキングツリーきれい?」と聞けばファイル状態を確認して口語体で結果を教えてくれる。ターミナルで同じことができるがキーボードから手を離さなくていいという点でそれなりの利便性がある。
ただし残念な点はレスポンス速度だ。音声で質問するとClaude Codeが分析しツールを呼び出し応答を生成するまでにそれなりの時間がかかる。簡単な質問でも数秒から十数秒待つことになり音声インターフェースではこの待ち時間の体感が大きい。
今はwhisper.cpp、Google Cloud TTS、Claude Code CLIを順番にパイプラインのようにつないだ構造なので各段階の遅延がそのまま積み重なる。既製品をそのまま組み合わせただけなのでこれが最善なのかという気もするが改善の余地はありそうだ。エンジニアリング面では全応答を待たずに最初の文が完成した時点からTTS合成を始めるストリーミング方式やprocessing状態に入った時点でオーディオセッションを事前初期化して再生開始の遅延をなくす方法などがありそうだ。LLM活用面では「今のブランチ教えて」のような簡単なクエリをHaiku級の軽量モデルに分岐したり音声専用のシステムプロンプトを別途用意してツール呼び出し不要で短く答えられる質問と分析が必要な質問を区別することも考えられる。git statusや現在のディレクトリなどよく聞かれる情報をローカルにキャッシュしておきLLM呼び出し自体をスキップする方法もある。今回はデモレベルなのでそこまで手を付けなかったがストリーミングTTSだけ実装しても体感はかなり変わりそうだ。
一番苦労したのはやはりWhisperのhallucinationだ。安全装置を一つ追加してテストしてまた別のパターンで突破されてまた塞いでの繰り返しだった。TTSの最初の音節が切れるのも原因を突き止めるまでかなりかかった。オーディオハードウェアの初期化遅延はログだけでは分からず無音挿入という解決策もバイト単位のヘッダー修正が必要で簡単ではなかった。
一方でアバターの表情は一番無駄な作業でありながら一番楽しかった。パラメータをいじりながら「これはかわいい」「これはちょっと怖い」と一人で遊んでいるうちに夜中の3時なんてあっという間に過ぎていく。
眠れない夜中の2時に始めたプロジェクトは日が昇ってもまだ続いていた。寝なきゃ…あれ、もう午前10時? それでもなかなか楽しく遊べた。
コメントする