• #claude-code
  • #voicevox
  • #hooks
開発claude-code-toolsメモ

Claude Codeのフックでずんだもんボイスをランダム再生する

Claude Codeのフック機能を使って、作業の開始・完了時にVOICEVOXのずんだもんボイスを鳴らす。ボイスのパターンを複数用意してランダムに再生する仕組みを作った。

完成イメージ

プロンプトを送信すると「了解なのだ!さっそく取りかかるのだ」などの作業開始ボイスがランダムに流れ、応答が完了すると「できたのだ!確認してほしいのだ」などの作業完了ボイスがランダムに流れる。

用意したボイスパターンは以下の通り。

ずんだもんボイスパターン一覧

作業開始(10パターン)

  1. 了解なのだ!さっそく取りかかるのだ
  2. おっけーなのだ!やるのだ
  3. 任せるのだ!ずんだパワー全開なのだ
  4. よーしやるのだ!腕がなるのだ
  5. ふっふっふ、このくらい朝飯前なのだ
  6. スイッチオンなのだ!集中モード発動なのだ
  7. まかせろなのだ!ずんだもんの本気を見せるのだ
  8. キックオフなのだ!試合開始の笛が鳴ったのだ
  9. ウォーミングアップ完了なのだ!全力で行くのだ
  10. 筋トレ前のストレッチみたいに準備万端なのだ

作業完了(10パターン)

  1. できたのだ!確認してほしいのだ
  2. 完了なのだ!
  3. どやっ!完璧に仕上げたのだ
  4. ミッションコンプリートなのだ!褒めてほしいのだ
  5. ふぅー、やりきったのだ!ずんだもちで乾杯なのだ
  6. はいできたのだ!天才かもしれないのだ
  7. お届けものなのだ!出来立てほやほやなのだ
  8. ゴーーール!見事に決めたのだ!
  9. 試合終了なのだ!完封勝利なのだ
  10. 筋トレ後のプロテインみたいに達成感マックスなのだ

前提

  • Windows環境
  • VOICEVOXがインストールされ、エンジンが起動している(http://localhost:50021
  • Python 3.x
  • Claude Code CLI

ファイル構成

voicevox-tts/
└── .claude/
    └── hooks/
        ├── play_voice.py           # 再生スクリプト(フックから呼ばれる)
        ├── generate_voice_cache.py # wav生成スクリプト
        └── voice_cache/
            ├── start_01.wav 〜 start_10.wav  # 作業開始
            ├── stop_01.wav  〜 stop_10.wav   # 作業完了
            ├── notification.wav              # 通知
            ├── ask_user.wav                  # 質問
            └── subagent_stop.wav             # サブエージェント完了

使用するフックイベント

Claude Codeには複数のフックイベントが用意されている。今回使うのは以下の2つ。

イベントタイミング用途
UserPromptSubmitユーザーがプロンプトを送信したとき作業開始ボイス
StopClaudeの応答が完了したとき作業完了ボイス

その他のイベント(PreToolUsePostToolUseNotificationSubagentStopなど)も利用できる。ただし、ツール実行のたびに鳴らすと鬱陶しいので、開始と完了だけにとどめるのが実用的。

設定手順

1. 音声ファイルの生成スクリプト

VOICEVOXのAPIを叩いてwavファイルを事前生成する。

# generate_voice_cache.py
"""イベント通知用の音声ファイルを事前生成するスクリプト

Usage: python generate_voice_cache.py [--only start|stop|single]
"""
import argparse
import json
import os
import urllib.parse
import urllib.request

CACHE_DIR = os.path.join(os.path.dirname(__file__), "voice_cache")

# 単発イベント(1イベント1ファイル)
SINGLE_MESSAGES = {
    "pre_tool_use": "ツール使うのだ",
    "post_tool_use": "終わったのだ",
    "subagent_stop": "サブエージェント終わったのだ",
    "notification": "お知らせなのだ",
    "ask_user": "選んでねなのだ",
}

# 作業開始パターン(ファイル名: start_01.wav 〜 start_10.wav)
START_MESSAGES = [
    "了解なのだ!さっそく取りかかるのだ",
    "おっけーなのだ!やるのだ",
    "任せるのだ!ずんだパワー全開なのだ",
    "よーしやるのだ!腕がなるのだ",
    "ふっふっふ、このくらい朝飯前なのだ",
    "スイッチオンなのだ!集中モード発動なのだ",
    "まかせろなのだ!ずんだもんの本気を見せるのだ",
    "キックオフなのだ!試合開始の笛が鳴ったのだ",
    "ウォーミングアップ完了なのだ!全力で行くのだ",
    "筋トレ前のストレッチみたいに準備万端なのだ",
]

# 作業完了パターン(ファイル名: stop_01.wav 〜 stop_10.wav)
STOP_MESSAGES = [
    "できたのだ!確認してほしいのだ",
    "完了なのだ!",
    "どやっ!完璧に仕上げたのだ",
    "ミッションコンプリートなのだ!褒めてほしいのだ",
    "ふぅー、やりきったのだ!ずんだもちで乾杯なのだ",
    "はいできたのだ!天才かもしれないのだ",
    "お届けものなのだ!出来立てほやほやなのだ",
    "ゴーーール!見事に決めたのだ!",
    "試合終了なのだ!完封勝利なのだ",
    "筋トレ後のプロテインみたいに達成感マックスなのだ",
]


def generate_wav(text, output_path, speaker=1, speed=1.0):
    base = "http://localhost:50021"
    encoded = urllib.parse.quote(text)

    req = urllib.request.Request(
        f"{base}/audio_query?text={encoded}&speaker={speaker}",
        method="POST",
    )
    req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req) as resp:
        query = json.loads(resp.read())
    query["speedScale"] = speed
    query["intonationScale"] = 1.3
    query["pitchScale"] = -0.02
    query["prePhonemeLength"] = 0.05
    query["postPhonemeLength"] = 0.05

    body = json.dumps(query).encode()
    req2 = urllib.request.Request(
        f"{base}/synthesis?speaker={speaker}",
        data=body,
        method="POST",
    )
    req2.add_header("Content-Type", "application/json")

    with urllib.request.urlopen(req2) as resp2:
        with open(output_path, "wb") as f:
            f.write(resp2.read())
    print(f"  {output_path} ({os.path.getsize(output_path)} bytes)")


def generate_numbered(prefix, messages):
    for i, text in enumerate(messages, 1):
        filename = f"{prefix}_{i:02d}.wav"
        path = os.path.join(CACHE_DIR, filename)
        print(f"  [{filename}] {text}")
        generate_wav(text, path)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--only", choices=["start", "stop", "single"])
    args = parser.parse_args()

    os.makedirs(CACHE_DIR, exist_ok=True)
    print("Generating voice cache files...")

    if args.only is None or args.only == "single":
        for name, text in SINGLE_MESSAGES.items():
            path = os.path.join(CACHE_DIR, f"{name}.wav")
            generate_wav(text, path)

    if args.only is None or args.only == "start":
        generate_numbered("start", START_MESSAGES)

    if args.only is None or args.only == "stop":
        generate_numbered("stop", STOP_MESSAGES)

    print("Done!")

VOICEVOXを起動した状態で実行する。

python generate_voice_cache.py           # 全部生成
python generate_voice_cache.py --only start  # 作業開始のみ
python generate_voice_cache.py --only stop   # 作業完了のみ

2. ランダム再生スクリプト

番号付きファイル({event}_01.wav{event}_NN.wav)があればランダムに1つ選んで再生する。番号付きファイルがなければ {event}.wav にフォールバックする。

# play_voice.py
"""キャッシュ済み音声ファイルを再生する(フックから呼ばれる)

Usage: python play_voice.py <event_name>
"""
import glob
import os
import random
import subprocess
import sys
import winsound

CACHE_DIR = os.path.join(os.path.dirname(__file__), "voice_cache")


def pick_wav(event):
    """番号付きファイルからランダム選択、なければ単一ファイルにフォールバック"""
    pattern = os.path.join(CACHE_DIR, f"{event}_[0-9][0-9].wav")
    numbered = glob.glob(pattern)
    if numbered:
        return random.choice(numbered)
    single = os.path.join(CACHE_DIR, f"{event}.wav")
    return single if os.path.exists(single) else None


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit(1)

    # --play <path>: デタッチドプロセスとして再起動された側 → 同期再生
    if sys.argv[1] == "--play" and len(sys.argv) > 2:
        winsound.PlaySound(sys.argv[2], winsound.SND_FILENAME)
        sys.exit(0)

    event = sys.argv[1]
    path = pick_wav(event)

    if not path:
        sys.exit(0)

    # デタッチドプロセスで自身を再起動して即リターン
    subprocess.Popen(
        [sys.executable, __file__, "--play", path],
        creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW,
        close_fds=True,
    )

デタッチドプロセスとして再起動する仕組みにしている。フックから呼ばれた時点では即リターンし、音声再生は別プロセスで行う。こうするとClaude Codeの処理をブロックしない。

3. settings.json のフック設定

~/.claude/settings.jsonhooks セクションに以下を追加する。

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python /path/to/play_voice.py start"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python /path/to/play_voice.py stop"
          }
        ]
      }
    ]
  }
}

/path/to/play_voice.py は実際のパスに置き換える。Windowsの場合はフォワードスラッシュ(C:/Users/.../play_voice.py)で記述する。

仕組みのまとめ

ユーザーがプロンプト送信
  → UserPromptSubmit フック発火
  → play_voice.py start
  → start_01.wav 〜 start_10.wav からランダム選択
  → デタッチドプロセスで再生

Claudeが応答完了
  → Stop フック発火
  → play_voice.py stop
  → stop_01.wav 〜 stop_10.wav からランダム選択
  → デタッチドプロセスで再生

VOICEVOXの音声パラメータ

generate_voice_cache.py では以下のパラメータを設定している。

パラメータ説明
speaker1ずんだもん(ノーマル)
speedScale1.0話速
intonationScale1.3抑揚(デフォルトより強め)
pitchScale-0.02ピッチ(わずかに低め)
prePhonemeLength0.05発話前の無音(短め)
postPhonemeLength0.05発話後の無音(短め)

speaker IDはVOICEVOXのキャラクターごとに異なる。http://localhost:50021/speakers で一覧を確認できる。

パターンを追加するには

  1. generate_voice_cache.pySTART_MESSAGES または STOP_MESSAGES リストにテキストを追加
  2. ファイル名の連番は自動で振られる
  3. python generate_voice_cache.py --only start で再生成
  4. play_voice.pypick_wav はglob({event}_[0-9][0-9].wav)で拾うので、ファイルを追加するだけで動く