10
8

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・Codex CLI・Gemini CLI の応答を VOICEVOX で読み上げる ― 声でツールを聞き分ける開発環境の作り方

10
Posted at

はじめに

Claude Code、Codex CLI、Gemini CLI ― いまや AI コーディングツールを複数使い分ける開発者は珍しくありません。しかし、コードを書きながらターミナルの出力をいちいち目で追うのは意外と負担です。

AI の応答を音声で読み上げてくれたら、画面から目を離さずに済むのに

そう思ったのがこのシステムを作ったきっかけです。さらに、ツールごとに異なるキャラクターの声を割り当てることで、「今しゃべっているのは Claude なのか Codex なのか Gemini なのか」が耳で判別できるようになります。

本記事では、VOICEVOX を使って 3 つの AI コーディングツールの応答をリアルタイムに読み上げるシステムの構築方法を、環境構築からスクリプト実装・設定まで一通り解説します。

完成イメージ

各ツールが応答を返すたびに、Hook 機構を通じて共通の speak.sh が呼ばれ、VOICEVOX で音声合成 → ALSA で再生、という流れです。ツールごとに話者を変えているので、声を聞くだけでどのツールの応答か分かります。

ツール 話者 声の印象
Claude Code ずんだもん(ID:3) 元気なキャラクター声
Codex CLI 四国めたん(ID:2) 落ち着いた女性声
Gemini CLI 春日部つむぎ(ID:8) 明るい女性声

前提環境

筆者の環境は以下のとおりです。Windows PC から VS Code Remote SSH で Linux サーバに接続し、サーバ側のヘッドホン出力から音声を再生しています。

項目 詳細
OS Ubuntu 24.04.4 LTS
CPU Intel Core i7-12700K
RAM 64 GB
GPU NVIDIA GeForce RTX 3070(VRAM 8 GB)
オーディオ Realtek ALC1220(ALSA 直接出力)
接続方式 Windows → VS Code Remote SSH → Linux

GPU は VOICEVOX の高速推論に使います。CPU のみでも動作しますが、応答速度が大きく変わります。

仕組みの全体像

なぜ Hook 機構を使うのか

3 つのツールにはそれぞれ「応答が完了したときに外部コマンドを実行する」仕組みがあります。

ツール 仕組みの名称 発火タイミング
Claude Code Hooks(Stop / Notification) 応答完了時・通知発生時
Codex CLI notify エージェントのターン完了時
Gemini CLI Hooks(AfterAgent) エージェント処理完了時

この Hook が発火すると、各ツールは応答内容を含む JSON データを外部スクリプトに渡します。ただし、渡し方がツールごとに異なるのがポイントです。

ツールごとのデータ受け渡しの違い

ここが最大のハマりどころでした。3 つのツールは JSON の渡し方がそれぞれ異なります。

ツール JSON の渡し方 テキストのフィールド名 特記事項
Claude Code stdin で渡される last_assistant_message
Codex CLI sys.argv[1] で渡される last-assistant-message stdin ではない!
Gemini CLI stdin で渡される prompt_response stdout に {} を返す必要がある

特に Codex CLI は stdin ではなくコマンドライン引数で JSON を渡してくる点、Gemini CLI は Hook スクリプトが stdout に有効な JSON を返さないとエラーになる点に注意が必要です。

Step 1: VOICEVOX Engine の準備

Docker + GPU 環境のセットアップ

VOICEVOX は Docker イメージが公式に提供されています。GPU 版を使うため、NVIDIA Container Toolkit が必要です。

# Docker のインストール
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER

# NVIDIA Container Toolkit のインストール
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
  | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
  | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
  | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

VOICEVOX Engine の起動

docker pull voicevox/voicevox_engine:nvidia-latest
docker run -d --name voicevox --gpus all -p 50021:50021 \
  voicevox/voicevox_engine:nvidia-latest

起動したら動作確認します。

# API の応答を確認
curl -s http://localhost:50021/version
# → "0.21.1" のようにバージョンが返れば OK

GPU を持っていない場合は、nvidia-latest の代わりに latest タグを使えば CPU 版で動作します。

Step 2: オーディオ出力の準備

ALSA のセットアップ

PulseAudio や PipeWire を使わず、ALSA で直接再生します。SSH 経由でもシンプルに動作するためです。

sudo apt install -y alsa-utils
sudo usermod -aG audio $USER
# ← SSH セッションの再接続が必要

再接続後、デバイスの確認とミュート解除を行います。

# デバイス一覧
aplay -l

# ミュート解除と音量設定
amixer -c 0 set Master unmute
amixer -c 0 set Master 70%

# テストトーン(440Hz のサイン波が 2 秒鳴れば OK)
speaker-test -D plughw:0,0 -t sine -f 440 -l 1 -p 2

VS Code SSH セッションでの注意点

VS Code Remote SSH はセッションをキャッシュするため、usermod でグループを追加しても反映されないことがあります。その場合は sg コマンドで回避できます。

# audio グループの権限で一時的にコマンドを実行
sg audio -c "aplay -D plughw:0,0 /tmp/test.wav"

本システムの speak.sh では、この sg audio -c をデフォルトで使用しています。

Step 3: 共通 TTS スクリプト(speak.sh)

3 つのツールすべてが最終的に呼び出す共通スクリプトです。VOICEVOX の REST API は 2 ステップで動作します。

speak.sh の実装

#!/bin/bash
VOICEVOX_URL="http://localhost:50021"
SPEAKER_ID=3  # デフォルト: ずんだもんノーマル
ALSA_DEVICE="plughw:0,0"
MAX_LENGTH=512
SPEED_SCALE=1.2  # 話速(1.0=標準、1.2=1.2倍速)

# -s オプションで話者 ID を切り替え
while getopts "s:" opt; do
    case $opt in
        s) SPEAKER_ID="$OPTARG" ;;
    esac
done
shift $((OPTIND - 1))

# テキスト取得(引数 or stdin)
if [ $# -gt 0 ]; then TEXT="$*"; else TEXT=$(cat); fi

# 空白除去・空チェック
TEXT=$(echo "$TEXT" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -z "$TEXT" ]; then exit 0; fi

# 長すぎるテキストは切り詰め
if [ ${#TEXT} -gt $MAX_LENGTH ]; then
    TEXT="${TEXT:0:$MAX_LENGTH}。以下省略"
fi

TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT

# URL エンコード
ENCODED_TEXT=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$TEXT")

# Step 1: audio_query(テキスト → 音声クエリ JSON)
curl -s -X POST "${VOICEVOX_URL}/audio_query?text=${ENCODED_TEXT}&speaker=${SPEAKER_ID}" \
    -o "${TMPDIR}/query.json" 2>/dev/null

if [ ! -s "${TMPDIR}/query.json" ]; then exit 1; fi

# 話速を変更
python3 -c "
import json, sys
with open(sys.argv[1], 'r') as f:
    q = json.load(f)
q['speedScale'] = float(sys.argv[2])
with open(sys.argv[1], 'w') as f:
    json.dump(q, f)
" "${TMPDIR}/query.json" "$SPEED_SCALE"

# Step 2: synthesis(音声クエリ JSON → WAV)
curl -s -X POST -H "Content-Type:application/json" \
    -d @"${TMPDIR}/query.json" \
    "${VOICEVOX_URL}/synthesis?speaker=${SPEAKER_ID}" \
    -o "${TMPDIR}/speech.wav" 2>/dev/null

if [ ! -s "${TMPDIR}/speech.wav" ]; then exit 1; fi

# 再生
sg audio -c "aplay -D ${ALSA_DEVICE} ${TMPDIR}/speech.wav" 2>/dev/null

設計上のポイント

なぜ話速を変えているのか? VOICEVOX のデフォルト速度(1.0)はやや遅く感じます。コーディング中は素早く結果を知りたいので、1.2 倍速に設定しています。/audio_query が返す JSON の speedScale フィールドを書き換えてから /synthesis に渡すことで実現しています。

なぜテキストを 512 文字で切り詰めるのか? AI の応答は数千文字になることがあります。全文を読み上げると数十秒かかり、次の操作の妨げになります。冒頭 512 文字で要点をつかめれば十分なので、それ以降は「以下省略」として切り詰めています。

Step 4: テキスト整形 ― 読み上げに不要な要素を除外する

AI コーディングツールの応答にはマークダウン形式のテキストが含まれます。コードブロックや表をそのまま読み上げると「バッククォート3つ、パイソン、デフ、メイン…」のように意味不明な音声になります。

そこで、読み上げ前にテキストを整形し、不要な要素を除外します。

除外対象の一覧

種別 判定条件 除外の理由
コードブロック ``` で囲まれた範囲 ソースコードの読み上げは不要
コマンド実行例 $ > で始まる行 シェルコマンドの読み上げは不要
インデントコード 4 スペース以上の行 マークダウンのコードブロック
| を含む行 表形式データの読み上げは不自然
リスト - * 1. で始まる行 箇条書きの読み上げは冗長
区切り線 --- で始まる行 視覚的な区切りであり音声不要
マークダウン記号 # * ` > 記号そのものに読み上げ価値なし
括弧とその中身 () () [] 【】 補足説明は音声では冗長

実装例(Claude Code 用: extract_text.py)

#!/usr/bin/env python3
import sys, json, re

def clean_markdown(text):
    lines = text.split('\n')
    cleaned = []
    in_code_block = False
    for line in lines:
        stripped = line.strip()
        # コードブロックの開始/終了
        if stripped.startswith('```'):
            in_code_block = not in_code_block
            continue
        if in_code_block:
            continue
        # コマンド例・インデントコード
        if stripped.startswith('$ ') or stripped.startswith('> '):
            continue
        if line.startswith('    ') and stripped:
            continue
        # 表・リスト・区切り線
        if '|' in stripped:
            continue
        if stripped.startswith('---') or stripped.startswith(':--'):
            continue
        if stripped.startswith('- ') or stripped.startswith('* '):
            continue
        if len(stripped) > 1 and stripped[0].isdigit() and '. ' in stripped[:5]:
            continue
        cleaned.append(stripped)
    text = ' '.join(cleaned)
    # マークダウン記号の除去
    for ch in ['#', '*', '`', '>']:
        text = text.replace(ch, '')
    # 括弧とその中身を除去
    text = re.sub(r'([^)]*)', '', text)
    text = re.sub(r'\([^)]*\)', '', text)
    text = re.sub(r'【[^】]*】', '', text)
    text = re.sub(r'\[[^\]]*\]', '', text)
    text = re.sub(r'{[^}]*}', '', text)
    text = re.sub(r'\{[^}]*\}', '', text)
    return ' '.join(text.split())

def main():
    event_type = sys.argv[1] if len(sys.argv) > 1 else 'stop'
    try:
        data = json.load(sys.stdin)
    except (json.JSONDecodeError, EOFError):
        sys.exit(0)

    if event_type == 'stop':
        msg = data.get('last_assistant_message', '')
        if msg:
            print(clean_markdown(msg))
    elif event_type == 'notification':
        msg = data.get('message', '')
        if msg:
            print(msg)

if __name__ == '__main__':
    main()

Step 5: 各ツールの Hook スクリプト

Claude Code 用(claude_hook.sh)

Claude Code は Hook 発火時に stdin で JSON を渡します。extract_text.py でテキストを抽出し、speak.sh に渡します。

#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SPEAK="${SCRIPT_DIR}/speak.sh"
EVENT_TYPE="${1:-stop}"

TEXT=$(python3 "${SCRIPT_DIR}/extract_text.py" "$EVENT_TYPE")
if [ -n "$TEXT" ]; then
    "$SPEAK" "$TEXT" &
fi

設定(~/.claude/settings.json):

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/claude_hook.sh stop",
            "timeout": 30000
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/claude_hook.sh notification",
            "timeout": 30000
          }
        ]
      }
    ]
  }
}

Codex CLI 用(codex_hook.py)

Codex CLI の最大の違いは、JSON が stdin ではなく sys.argv[1] で渡される点です。また、テキストのフィールド名がハイフン区切り(last-assistant-message)である点にも注意が必要です。

#!/usr/bin/env python3
import json, subprocess, sys, os, re

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SPEAK = os.path.join(SCRIPT_DIR, "speak.sh")
SPEAKER_ID = "2"  # 四国めたんノーマル

def clean_markdown(text):
    # (extract_text.py と同じフィルタリング処理)
    ...

def main():
    if len(sys.argv) < 2:
        return 0
    try:
        data = json.loads(sys.argv[1])  # ← stdin ではなく argv[1]
    except (json.JSONDecodeError, IndexError):
        return 0

    if data.get("type") != "agent-turn-complete":
        return 0

    msg = data.get("last-assistant-message", "")  # ← ハイフン区切り
    if not msg:
        return 0

    text = clean_markdown(msg)
    if not text:
        return 0

    subprocess.Popen([SPEAK, "-s", SPEAKER_ID, text])
    return 0

if __name__ == "__main__":
    sys.exit(main())

設定(~/.codex/config.toml):

notify = ["python3", "/path/to/codex_hook.py"]

Gemini CLI 用(gemini_hook.sh)

Gemini CLI は stdin で JSON を渡しますが、Hook スクリプトが stdout に有効な JSON を返さなければならないという独自の制約があります。最低限 {} を返す必要があります。

#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SPEAK="${SCRIPT_DIR}/speak.sh"
SPEAKER_ID=8  # 春日部つむぎ ノーマル

# stdin から JSON を読み取り
INPUT=$(cat)

# リトライ中(stop_hook_active=true)ならスキップ
STOP_ACTIVE=$(echo "$INPUT" | python3 -c \
  "import sys,json; print(json.load(sys.stdin).get('stop_hook_active',False))" 2>/dev/null)
if [ "$STOP_ACTIVE" = "True" ]; then
    echo '{}'
    exit 0
fi

# テキスト抽出(インライン Python で clean_markdown を実行)
TEXT=$(echo "$INPUT" | python3 -c "
import sys, json, re
# ... clean_markdown 関数(前述と同じ)...
data = json.load(sys.stdin)
msg = data.get('prompt_response', '')
if msg:
    print(clean_markdown(msg))
" 2>/dev/null)

if [ -n "$TEXT" ]; then
    "$SPEAK" -s "$SPEAKER_ID" "$TEXT" >/dev/null 2>&1 &
fi

# Gemini CLI に JSON 応答を返す(これが無いとエラーになる)
echo '{}'
exit 0

設定(~/.gemini/settings.json):

{
  "hooks": {
    "AfterAgent": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/gemini_hook.sh",
            "timeout": 30000
          }
        ]
      }
    ]
  }
}

Step 6: ユーザー辞書で専門用語の読みを改善する

ここまでで基本的な読み上げは動作しますが、実際に使ってみると問題に気づきます。

VOICEVOX は英語の技術用語をまともに読めません。

たとえば「SSH」は「シュシュフ?」、「API」は「アピ」のように発音されます。これではストレスが溜まるだけです。

幸い、VOICEVOX にはユーザー辞書 API があります。TSV ファイルで辞書を管理し、スクリプトで一括登録する仕組みを作りました。

辞書ファイル(dict.tsv)

# 形式: 表記<TAB>読み(カタカナ)<TAB>アクセント型
SSH	エスエスエイチ	0
API	エーピーアイ	0
JSON	ジェイソン	0
Docker	ドッカー	0
GitHub	ギットハブ	0
Claude	クロード	0
Codex	コーデックス	0
Gemini	ジェミニ	0
GPU	ジーピーユー	0
NVIDIA	エヌビディア	0

筆者の環境では 56 語を登録しています。プログラミング用語、AI ツール名、ハードウェア用語などをカバーしています。

登録スクリプト(register_dict.sh)

#!/bin/bash
DICT_FILE="$(cd "$(dirname "$0")" && pwd)/dict.tsv"
VOICEVOX_URL="http://localhost:50021"

while IFS=$'\t' read -r surface pronunciation accent_type; do
    [[ "$surface" =~ ^#.*$ ]] && continue
    [[ -z "$surface" ]] && continue
    accent_type=${accent_type:-0}

    encoded_surface=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$surface")
    encoded_pron=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$pronunciation")

    curl -s -X POST \
        "${VOICEVOX_URL}/user_dict_word?surface=${encoded_surface}&pronunciation=${encoded_pron}&accent_type=${accent_type}&word_type=PROPER_NOUN" \
        >/dev/null
    echo "登録: ${surface}${pronunciation}"
done < "$DICT_FILE"
# 使い方
./register_dict.sh

# 既存エントリをクリアして再登録する場合
./register_dict.sh --clean

Docker コンテナを docker rm + docker run で再作成すると辞書は消えます。docker stop / docker start では保持されます。コンテナ再作成後は register_dict.sh を再実行してください。

3 つのツールの違いまとめ

実装してみて分かった、各ツールの Hook 機構の違いをまとめます。

項目 Claude Code Codex CLI Gemini CLI
Hook の名称 Hooks notify Hooks
設定ファイル ~/.claude/settings.json ~/.codex/config.toml ~/.gemini/settings.json
設定形式 JSON TOML JSON
データの渡し方 stdin sys.argv[1] stdin
テキストフィールド last_assistant_message last-assistant-message prompt_response
stdout の制約 なし なし JSON 必須
イベント種別 Stop / Notification agent-turn-complete AfterAgent

最もハマりやすいのは Codex CLI の argv 渡しGemini CLI の stdout JSON 必須です。この 2 点を知っているだけで、実装時間が大幅に短縮できるはずです。

トラブルシューティング

音が出ない

VOICEVOX が応答しない

# コンテナの状態確認
docker ps | grep voicevox

# 停止していたら起動
docker start voicevox

# ログ確認
docker logs voicevox --tail 20

VS Code SSH でグループが反映されない

VS Code はセッションをキャッシュするため、usermod 後もグループが反映されないことがあります。sg コマンドで回避するか、VS Code の Remote SSH 接続を完全に切断して再接続してください。

まとめ

  • Claude Code・Codex CLI・Gemini CLI の応答を VOICEVOX で音声読み上げするシステムを構築しました
  • ツールごとに異なる話者を割り当てることで、声だけでツールを識別できます
  • テキスト整形により、コードブロック・表・リスト・括弧の中身など読み上げに不要な要素を除外しています
  • VOICEVOX のユーザー辞書 API で技術用語の読みを改善しています
  • 3 ツールの Hook 機構のデータ渡し方の違い(stdin vs argv、stdout JSON 必須)が最大のハマりどころです

画面を見つめ続ける開発スタイルから、耳でも情報を受け取れる開発スタイルへ。小さな工夫ですが、マルチタスク時の快適さが確実に上がります。ぜひ試してみてください。

10
8
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
10
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?