はじめに
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 必須)が最大のハマりどころです
画面を見つめ続ける開発スタイルから、耳でも情報を受け取れる開発スタイルへ。小さな工夫ですが、マルチタスク時の快適さが確実に上がります。ぜひ試してみてください。