Claude Codeのターンが終わったら好きな声に喋ってもらう ―― Hookで自宅GPUのTTSを公開した話

Claude Codeに作業を投げたあと、Claudeの作業が終わっていたり、中断されていたのにしばらく気づかなかった、という経験はありませんか?

個人的にこの「終わったことに気づかない問題」がずっと地味なストレスでした。

AIモデルの性能が上がれば上がるほど思考時間も伸び、ただただ画面を見つめて待っている時間がキツくなってきて、別の作業を始めたが最後、AIが止まっていたことに数時間後に気づくなんてこともザラ。

そこでまずHookを活用して応答が終わったらビープ音を鳴らすようにしてみました。これだけでも「気づかずに放置」はかなり減りましたが、さらに欲が出てきたんです。

喋ってほしい。声をかけてほしい。

AIって結構イラつかされることが多いと思っています。きっと皆さんもそうでしょう。

だけど、もし自分の好みの声で喋ってくれたらそのイラつきも減るに違いないのです。

それにビープだけだと『何が』終わったのかは分からないんですよね。

音声で喋ってくれれば、今来た通知が「何についての返事なのか」を短い一言で把握できる。特に複数の作業を並行で投げているとき、別の画面を見ながらでも「あ、今のはあの作業の返事だ」と分かるのは、結構便利なんです。

こうして、まずビープ音、そこから音声化へという順で育てていきました。

というわけで今回は、Claude CodeのHookを使って、AIの返事が終わった瞬間に「その返事の内容を要約したセリフ」を自前のTTS(音声合成)で喋らせる仕組みを、ローカルから自宅GPUのリモート公開までやった話です。

ハマったところも書いておきます。

好きな声が選べる

百聞は一聴にしかず。実際にこの仕組みが喋ると、こんな感じです(セリフは「AIの作業が終わったよ。確認してね。」)。

この仕組みの面白いところは、声を好きに差し替えられること。試しに、自由に使える音声モデルを11種類つないでみました。同じセリフでも、声でこんなに表情が変わります。(おまけなので、軽く流し聞きでどうぞ。声が出てくるので注意してください。)


再生順(カッコ内は再生位置):

  • アルファパラダイス:0:00 デルタもん / 0:03 ガンマミィ / 0:08 ベータミーナ / 0:13 橘涼子 / 0:18 比良坂つばめ / 0:22 白石彩花 / 0:27 日向あんな
  • Style-Bert-VITS2 同梱・フリー素材:0:33 あみたろ / 0:36 小春音アミ / 0:40 JVNV-F2(女声)/ 0:44 JVNV-M2(男声)

もちろん、自分で用意・学習した音声モデルを使うこともできます。私は別の音声モデルを使っていますし、ネットでよく聞くずんだもんとかも使えます。声は完全に差し替え自由です。

  • ※アルファパラダイス勢(デルタもん・ガンマミィ・ベータミーナ・橘涼子・比良坂つばめ・白石彩花・日向あんな)の音声は、アルファパラダイス(BlendAI)の音声合成モデルを使用しています。AI利用・商用利用・公開いずれも自由に許諾されています。出典:アルファパラダイス公式利用規約
  • ※「あみたろ」「小春音アミ」の音声は、あみたろの声素材工房(https://amitaro.net/)の声を用いたAI音声合成です(あみたろ本人・小春音アミ公式が作成・発声したものではありません)。
  • ※「JVNV-F2 / JVNV-M2」は、Style-Bert-VITS2に同梱されているJVNVコーパス由来のモデルです。

そもそもHookって何?

Hookは、ざっくり言うと「Claude Codeの特定のタイミングで、好きなプログラムを勝手に走らせられる仕組み」です。中身は完全に自由。シェルスクリプトでもPowerShellでも、何でも実行できます。

今回使うのはこの2つ。

Stop AIの返事が終わった瞬間に発火。→「できたよ」系を喋らせる
Notification 許可を求めてきた時/しばらく放置された時に発火。→「ねえ、確認して」系

つまり「返事が終わった」と「呼ばれてる」を、別々の音で知らせ分けられるわけです。もちろん他にもフックしたい場面があればそれに応じた処理も入れられます。

全体像

先に完成形の地図を出しておきます。最初はローカル(自宅PC)だけで完結する話、後半でそれを外に公開する話、という二部構成です。

【ローカル編】
Claude Code 応答終了
   │ Stop Hook 発火
   ▼
notify_stop.ps1
   1. 会話ログ(JSONL)の末尾を読む
   2. 応答末尾の「セリフ」を正規表現で抜き出す
   3. localhost:5000 の音声合成サーバーに投げる
   4. 返ってきたWAVを再生(失敗したらビープ)
   ▼
Style-Bert-VITS2(GPU推論・音声モデルは差し替え自由)

【リモート編(後半)】
会社PC ──HTTPS──▶ tts.example.com(Cloudflareのエッジ)
                       │ トンネル(自宅から外への接続)
                       ▼
                自宅PCの cloudflared ─▶ localhost:5000

ローカル編:応答の内容をそのまま喋らせる

仕掛けのキモ:セリフをHTMLコメントで埋め込む

一番工夫したのはここです。AI自身に、返事の末尾へ喋らせたいセリフを埋め込ませる。こんな感じに。

(普通の返事の本文 …)

<!-- voice: バグ直したよ、置換漏れだった -->

HTMLのコメント形式にしているのにはちゃんと理由があって、Claude Codeの画面ではコメントが薄い色で表示されるんです。つまり完全に消えるわけではないけれど、本文に埋もれて目立たない。「裏方のメモ書き」みたいに、邪魔にならず末尾へそっと添えられる、というわけです。

それにセリフは固定じゃありません。返事を書くときにAIがその場で内容を要約して入れるので、毎回違う。「聞こえる?」みたいな汎用句じゃなく「容量は心配ないよ」みたいに中身が分かる一言にしておくと、聞いただけで状況が掴めて便利です。

喋るのは:Style-Bert-VITS2

音声合成にはStyle-Bert-VITS2を使っています。選んだ一番の理由は日本語の読みの精度が高いこと。漢字の読み間違いが少なくて、短いセリフでも変なイントネーションになりにくいんですよね。REST APIを持っていて、localhost:5000にリクエストを投げると合成済みのWAVが返ってくる、という親切設計でもあります。

http://localhost:5000/voice?text=(URLエンコードしたテキスト)&model_id=8&speaker_id=0&style=Neutral&language=JP

model_id は、使いたい音声モデルのID。ここを変えれば声を差し替えられます。GPUで推論しているので、ウチのちょっと世代遅れのGPUでも15文字くらいのセリフなら1〜2秒で返ってきます。十分速い。

声の部分は完全に差し替え自由です。Style-Bert-VITS2に対応したモデルなら何でも挿せますし、自分で用意・学習したモデルを使うこともできます。冒頭で聴いてもらったサンプルは、AI利用が自由に許諾されているアルファパラダイスの声をお借りして作りました。

Hook本体(notify_stop.ps1)

PowerShellで書いたStop Hookの中身です。長いので要点だけ。やってることは「ログ読む→セリフ抜く→投げる→鳴らす」、それだけです。

$VOLUME = 0.7   # 音量は1箇所で管理

# WAV再生(音量を変えたいので MediaPlayer を使う)
function Invoke-WavPlayback([string]$path) {
    Add-Type -AssemblyName PresentationCore
    $player = New-Object System.Windows.Media.MediaPlayer
    $player.Volume = $VOLUME
    $player.Open([uri]::new($path))
    # Open直後は長さが取れないので、取れるまでちょっと待つ
    $t = 0
    while ((-not $player.NaturalDuration.HasTimeSpan) -and $t -lt 100) {
        Start-Sleep -Milliseconds 50; $t++
    }
    $player.Play()
    # 再生し終わるまで待つ(待たないと途中でブツ切れる)
    $ms = [int]$player.NaturalDuration.TimeSpan.TotalMilliseconds + 200
    Start-Sleep -Milliseconds $ms
    $player.Stop(); $player.Close()
}

# 何かあったら「ドミソ」のビープにフォールバック
function Invoke-FallbackBeep {
    [console]::beep(523,120); [console]::beep(659,120); [console]::beep(784,120)
}

try {
    # Hookは標準入力でJSONをくれる。transcript_path が会話ログのパス
    $payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
    $transcript = $payload.transcript_path

    # 末尾100行だけ読めば最新の返事は必ず入っている(全部読む必要なし)
    $lines = Get-Content $transcript -Tail 100 -Encoding UTF8

    # 末尾から遡って、最新のAIの返事テキストを集める
    $assistantText = $null
    for ($i = $lines.Length - 1; $i -ge 0; $i--) {
        try { $entry = $lines[$i] | ConvertFrom-Json } catch { continue }
        if ($entry.type -ne 'assistant') { continue }
        $sb = New-Object System.Text.StringBuilder
        foreach ($c in $entry.message.content) {
            if ($c.type -eq 'text') { [void]$sb.Append($c.text) }
        }
        if ($sb.ToString().Trim()) { $assistantText = $sb.ToString(); break }
    }
    if (-not $assistantText) { Invoke-FallbackBeep; exit 0 }

    # voiceマーカーを正規表現で抜く
    $match = [regex]::Match($assistantText, '<!--\s*voice:\s*(.+?)\s*-->', 'Singleline')
    if (-not $match.Success) { Invoke-FallbackBeep; exit 0 }
    $voiceText = $match.Groups[1].Value.Trim()

    # ★PS 5.1 には HttpUtility がないので [uri]::EscapeDataString を使う(後述、ここでも死にました)
    $enc = [uri]::EscapeDataString($voiceText)
    $tempWav = Join-Path $env:TEMP "claude_voice_stop.wav"
    $url = "http://localhost:5000/voice?text=$enc&model_id=8&speaker_id=0&style=Neutral&language=JP"

    Invoke-WebRequest -Uri $url -OutFile $tempWav -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
    Invoke-WavPlayback $tempWav
    Remove-Item $tempWav -Force   # 毎回上書き&即削除なのでディスクに溜まらない
}
catch { Invoke-FallbackBeep }
exit 0

ポイントを3つだけ。

  • 入力は標準入力のJSON。transcript_pathで会話ログの場所が渡ってきます。
  • 生成したWAVは毎回同じ名前で上書き→鳴らしたら即削除。チリも積もらない設計です。
  • どこでコケても最後はビープに落ちる。TTSサーバーが死んでてもClaude Code自体は止まりません。

許可待ちも知らせたい:Notification Hook

「許可して」の通知まで、いちいちテキスト→音声合成→再生……とやると数秒待つことになります。注意喚起なのに遅い。本末転倒ですよね。

なのでNotificationの方は、あらかじめ好きな声で8個のセリフをWAVにして用意しておき、発火時にランダムで1個鳴らすだけにしました。生成しないので待ち時間ゼロ。

try {
    $files = Get-ChildItem "C:\Users\aleaf\.claude\sounds\notify" -Filter *.wav
    $pick = $files | Get-Random        # キャッシュからランダムに1個
    Invoke-WavPlayback $pick.FullName
}
catch { [console]::beep(900,150); [console]::beep(700,150) }

「終わったよ」系と「呼んでるよ」系で声色やセリフを変えておくと、画面を見てなくても耳だけで区別できます。これが地味に効く。

settings.json で繋ぐ

あとは設定ファイルにHookとしてこのPowerShellたちを登録するだけ。

{
  "hooks": {
    "Stop": [
      { "matcher": "*",
        "hooks": [{ "type": "command",
          "command": "powershell -ExecutionPolicy Bypass -File \"C:\\Users\\aleaf\\.claude\\sounds\\notify_stop.ps1\"" }] }
    ],
    "Notification": [
      { "matcher": "permission_prompt|idle_prompt",
        "hooks": [{ "type": "command",
          "command": "powershell -ExecutionPolicy Bypass -File \"C:\\Users\\aleaf\\.claude\\sounds\\notify_input.ps1\"" }] }
    ]
  }
}

これで完成! ……と、ここまでスラスラ書きましたが、当然そんなに簡単には終わりませんでした。ここからが本編です。

ハマったところ

その1:長文を一気に渡すとTTSサーバーが固まる

一度、Style-Bert-VITS2のサーバーが固まりました。原因は、一度に長いテキストを投げたこと。クラッシュではなく応答停止で、Server.batの再起動が必要でした。

どのくらいの長さで固まるかはGPUの性能次第です。前に書いたとおりうちのGPUは少し前の世代なので、なおさらシビア。短い文章なら平気でも、長文をドンと渡すと厳しいようでした。正確な限界は環境によるので、自分のマシンで「このくらいなら問題ない」ラインを掴んでおくのがよさそうです。

対策は全部クライアント側(PowerShell)で済ませました。長い文章は句読点で短いチャンクに分割 → 並列で生成 → できた順に再生。こうすれば、どんな長さの文章でもサーバーを固めずに喋ってくれます。

その2:関数名のリネーム漏れという凡ミス

仕組みはできたはずなのに、なぜかビープばかり鳴る。デバッグログを仕込んで犯人を探したら再生関数を Play-Wav から Invoke-WavPlayback に改名したとき、呼び出し側を1箇所だけ直し忘れていました。

存在しない関数を呼ぶ→例外→catch→ビープ。もちろん。

初歩的なミスですが、AIは結構やります。

その3:PlaySyncの罠と音量問題

WAV再生のいちばん手軽な方法はこれです。

(New-Object Media.SoundPlayer "C:\path\sound.wav").PlaySync()

ここで .Play() ではなく .PlaySync() が必須。.Play() だとPowerShellのプロセスが終わった瞬間に音がブツッと切れます。Hookはすぐ終わるので、全部切れる。

ただ、この SoundPlayer音量を変えられない。70%くらいに絞りたかったので、結局 MediaPlayer(音量プロパティあり)に乗り換えました。代わりにこっちは「再生し終わる時間ぶん自分で待つ」必要があって、ちょっと面倒……。便利と面倒はいつもセットですね。

その4:PowerShell 5.1にHttpUtilityがない

URLエンコードに [System.Web.HttpUtility]::UrlEncode を使おうとしたら、PowerShell 5.1では読み込まれていなくて失敗。エンコードされていない日本語がそのまま飛んで、サーバーが 422エラーを返してきました。

正解はこっち。標準で動きます。

$enc = [uri]::EscapeDataString($voiceText)

ちなみにパラメータの形式が分からなくて詰まったときは、Style-Bert-VITS2の /docs(OpenAPIのページ)を見ると一発でした。困ったら公式の定義を見る。基本ですね。

その5:Notification Hookだけ効かない

Stopはすぐ動いたのに、後から足したNotificationがうんともすんとも言わない。原因は、Claude Codeがセッション開始時にHookを読み込むから。設定ファイルを書き換えても、今動いているセッションには反映されないことがあるんです。

Claude Codeを再起動したら、あっさり鳴りました。「設定したのに動かない」の大半はこれだと思います。

欲が出た:会社からも自宅GPUを使いたい

ここまでで自宅PCのローカルは完成。満足……していられたらよかったんですが、人間は欲深い生き物です。「会社のPCからも、自宅GPUの音声合成を使えたらいいなぁ」と思ってしまいました。

VPSに載せる手もあるんですが、Style-Bert-VITS2はGPU前提。VPSのCPUだと1セリフに5〜15秒かかって、リアルタイム用途にはつらい。GPU付きVPSは高い。

そこで出した結論が、自宅PCをCloudflare Tunnelで外に公開するです。GPUはそのまま活かせて、追加コストはゼロ。

なぜCloudflare Tunnelなのか

個人で自宅のサーバーを外に出すとき、Cloudflare Tunnelは本当に強いです。

月額 0円(個人プランで十分)
ルーターのポート開放 不要(自宅から外へ繋ぎにいくだけ)
固定グローバルIP 不要
SSL証明書 自動で発行してくれる

ポート開放も固定IPもいらない。これが効きます。自宅から外向きに繋ぎにいく形なので、ルーターに穴を開けずに済むんですよね。

開通までの手順

  • 自宅PCに cloudflared をインストール(WindowsはMSI推奨。サービス化できるので)
  • Cloudflareのダッシュボードでトンネルを新規作成(プランはFree)
  • 表示されるコマンドでトンネルをWindowsサービスとして登録。これでPC起動時に毎回自動で繋がる
  • 公開ホスト名を設定:tts自分が持っているドメイン(自宅用の個人ドメイン。本記事では example.com と表記します)を localhost:5000 に向ける

これで https://tts.example.com/models/info を叩いてローカルと同じJSONが返ってきたら開通です。結構詰まるかなぁと思ったんですが、claude君は詰まることなくやってくれました。進化を感じます。これで世界中どこからでも自宅GPUの音声合成が呼べるのです。

CloudflareのバグとCTログ

開通して浮かれていたんですが、ここからも少しだけハマりました。

「URLを秘密にすればいい」は通用しない

トンネルを開けた直後の状態は無認証。URLを知っていれば誰でも自宅GPUを使えてしまいます。

「いやいや、サブドメインなんて公開してないんだから、誰にもバレないでしょ」と思うかもしれませんが、それは通用しません。

HTTPSの証明書を発行すると、その存在が Certificate Transparency(CT)ログという公開ログに必ず記録されます。crt.sh というサイトで誰でも「このドメインで発行された証明書」を検索できる。botはこれを常時スキャンして、新しく出てきたサブドメインを片っ端から叩きにきます。つまり、黙っていても tts.example.com の存在は数日〜数週間で確実にバレる。

「URLを公開してないから安全」は幻想です。認証は最初から付ける前提で考えるべきかと思います。大した中身じゃなかったとしてもどんな悪用されるかはわかりませんので。

認証を付けようとしたら、Cloudflare側のバグで詰んだ

本来はCloudflare AccessのService Tokenで、「特定のヘッダを持ったリクエストしか通さない」という認証をかけるのが正攻法です。

curl -H "CF-Access-Client-Id: <ID>" \
     -H "CF-Access-Client-Secret: <SECRET>" \
     "https://tts.example.com/models/info"

……が、ここで本当に詰みました。Service Token自体は作れるのに、それを使うApplicationが作れない。「Active planが必要」と拒否され、Freeプランは既に有効なのにボタンがグレーアウト。

おかしいと思って調べたら、これCloudflare側の既知の不具合でした。コミュニティに同じ症状の報告が複数、しかも数時間前の新規投稿もある。つまり今まさに進行中の問題。私の設定ミスじゃなかった……ホッとしたような、解決しないという意味で困ったような。

回避策としては、左メニューから直接開くと詰むので「Discoverタブのウィザード経由」だとApplication作成まで進めることがある、という小技にたどり着きました。Cloudflare側のバグなので、これを読んでいる頃には直っているかもしれません。

なお、発行した認証情報はGit管理の外に置いて、絶対にコミットしない。これだけは徹底してください。バレたら自宅GPUを使い放題にされて、電気代だけが溶けていくかもしれません。まぁないでしょうが。

まとめ:設計のキモ

長くなったので、効いた設計判断だけ並べておきます。

  • セリフはAI自身に応答へ埋め込ませる(HTMLコメント方式)。別の通信路がいらず、毎回内容に合わせられる。
  • 動的生成とキャッシュを使い分ける。内容依存のStopは都度生成、速さが命のNotificationはキャッシュをランダム再生。
  • 失敗は必ずビープに落とす。TTSが死んでも本体は止めない。
  • 長文はTTSが固まるので分割して渡す。句読点で短く区切って並列生成すれば、どんな長さでも動く。
  • GPUはローカルに置いたままTunnelで公開。ポート開放も固定IPも不要で0円。
  • 無認証公開はCTログで必ずバレる。認証は最初からやる。

画面を見ていなくても、自分の好きな声で「できたよ」と言ってもらえる。たったそれだけのことなんですが、開発のテンションが地味に、しかし確実に上がります。

自分の好きな声に作業の完了を報告してもらうことで心なしかClaudeに怒ることも減ったような気がします。きっと。

ここまで読んでいただきありがとうございました。

  • ※認証情報・トンネルトークンなどの秘密情報は、記事用にすべて伏せ字・プレースホルダにしています。
  • ※Cloudflare AccessのApplication作成不具合は執筆時点の状況です。現在は解消されている可能性があります。
この記事を気に入ったら

この記事を書いた人

葉っぱ一号

葉っぱ一号

おいしいお店を探すのが好きです。おいしいお店のために遠出もしちゃいます。遠出したい衝動のためにおいしいお店を探すのかもしれません。そんなものですよね。

この人が書いた記事を見る >>