0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Google Cloud Text-to-Speechの使い方

0
Last updated at Posted at 2026-09-27

この記事は、2026年8月23日に書いた「ソファーで本を読みながらClaude Codeを使う——指示は口で、返事は耳で」の続編です。前回の記事を読んでいなくても分かるように書きます。

何をしたくて作ったのか

Claude Code を、画面を見ずに、キーボードにも触らずに使いたい。ここから始めました。

ソファーで本を読みながら、iPhone に「さっきのテスト、どうなった?」と話しかけます。数秒後、部屋の向こうの Mac から「テストは12件、すべて通りました」と声が返ってきます。そのあいだ、本から目を離しません。

この往復は、行きと帰りの2つでできています。

  • 行き:iPhone に話した文が、Mac で動いている Claude Code に指示として届く。前々回の記事で作りました
  • 帰り:Claude Code の返事が、Google Cloud Text-to-Speech の声になって Mac のスピーカーから出る。前回の記事で作りました

この記事で作り直すのは、帰りのほうです。前回の記事の「手順3/返事を声で返す」にあたります。行きが無くても使えます。キーボードで打った指示への返事も、同じように声で聞けます。

前回の記事はこちらです。

前回、返事を声にしていたのは、CLAUDE.md に書いた1行でした。声で届いた指示に返事をするときは、要点を読み上げスクリプトでも読むこと、という指示です。1か月ほど使うと、この1行では足りない場面が出てきました。画面には返事が出ているのに、声が出ない。声が出ても、画面の返事とは違うことを言っている。

読み上げるかどうかを Claude の判断に任せているかぎり、うまくいく回といかない回が残ります。そこで読み上げを、Claude Code の Stop フック1へ移しました。返事を書き終えるたびに Python のスクリプトが動き、会話の記録から最後の返事を取り出して、声に向く形に整えてから読み上げます。CLAUDE.md の1行は消しました。

9章には、前回と前々回の記事から変わった「行き」(iPhone から Mac へ声が届く側)の話もまとめました。

この記事を読むと明日できること

Claude Code の返事が終わるたびに自分のスクリプトを動かし、画面に出た最後の返事を文字のまま取り出せます。取り出した文は、読み上げでも通知でも、好きなところへ回せます。

読み上げに回す例として、Google Cloud Text-to-Speech の1回5,000バイトの上限に合わせて返事を400文字ずつに分け、1つを再生しているあいだに次を合成するスクリプトも載せました。1回に読んだ文字数は平均380文字で、無料枠は月100万文字です。

前提を先に書いておきます。

  • 試した日と環境:2026年9月26日・macOS 13.7.8・Claude Code 2.1.280
  • Python は Mac に最初から入っている /usr/bin/python3(3.9.6)で動きます。追加のパッケージは使いません
  • Google Cloud の準備と google_tts_synth.sh は、前回の記事のものを使います(7章で1か所だけ足します)
  • スクリプトの全体は GitHub に置きました。本文の talk_stop_hook.py は抜粋です:https://github.com/wakana19811111-source/claude-code-sofa-voice
  • 専門用語の説明は脚注にしました。本文の番号を押すと説明へ飛び、説明の末尾の記号で元の場所へ戻れます

作ったもの

返事が終わってから声が出るまでの流れです。

増えたファイルは3つ、差し替えたファイルが1つです。どれも ~/Library/Scripts/ に置きます。

# ファイル 役割
1 talk_stop_hook.py Stop フックの本体。最後の返事を取り出し、整えて読み上げに渡す
2 talk_reading_fixes.json 読み間違える語の辞書と、声から外す行の一覧
3 talk_mode.on 読み上げのオン・オフ。このファイルがあればオン
4 google_tts_say.sh(差し替え) 長い返事を400文字ずつに分けて、再生しながら次を合成する

前回との違いをまとめるとこうなります。

# 前回(CLAUDE.md の1行) 今回(Stop フック)
1 読むかどうか Claude がその回ごとに決める スクリプトが決まった条件で決める
2 読む中身 Claude が書いた要点 画面に出た返事を整えたもの
3 画面と声の食い違い 起きる 同じ文から作るので起きない

1. 前の作りで起きていたこと

前回、CLAUDE.md にはこう書いていました。

- iPhoneからの音声入力(【iPhone音声】で始まる行)に返事をするときは、
  要点を ~/Library/Scripts/google_tts_say.sh leda "……" でも読み上げること

この書き方だと、Claude は1回の返事の中で2つの文を作ります。画面に出す返事と、読み上げスクリプトに渡す短い文です。声のほうは「要点を」という指示に合わせて、画面とは別に書き直された文でした。

使っているうちに、3つのことが起きました。

# 起きたこと 理由
1 画面に返事が出たのに、声が出ない 読み上げスクリプトを呼ぶかどうかも、Claude がその回ごとに決めていた
2 声が画面の一部しか言わない 指示が「要点を」だったので、Claude は短くまとめた文を渡していた
3 声で「あとで画面に出します」と言うが、画面にはもう出ている 声の文と画面の文を別々に作っていたので、言うことがずれた

2つ目と3つ目は、Claude が指示のとおりに動いた結果です。読み上げを「Claude が毎回判断してやる作業」として置いていたことに原因があります。

そこで、読むかどうかと何を読むかを、決まった規則で動くスクリプトに移しました。声の文は、画面に出た返事そのものから作ります。

2. Stop フックを登録する

Claude Code は、Claude が1回の返事を書き終えるたびに Stop フックを呼びます。~/.claude/settings.json にこう足します。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 /Users/あなたのユーザー名/Library/Scripts/talk_stop_hook.py",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

settings.json にもう中身があるときは、"Stop" の部分だけを既存の "hooks"2 の中へ足してください。前回 SessionStart を登録した人なら、その隣に並べる形になります。

フックが起動されると、標準入力に JSON が1つ届きます。ほかにも項目がありますが、使うのはこの3つです。

{
  "session_id": "0f3c1d2e-…",
  "transcript_path": "/Users/あなたのユーザー名/.claude/projects/…/0f3c1d2e-….jsonl",
  "stop_hook_active": false
}
# 項目 中身 使い道
1 session_id いまのセッションの ID3 読み上げるセッションを絞る(8章)
2 transcript_path 会話の記録4のファイルの場所 最後の返事を取り出す(3章)
3 stop_hook_active Stop フックのせいで Claude が続きを書いている最中なら true true なら何もしない

このスクリプトは、何が起きても終了コード0で終わり、標準出力に何も出さない作りにしました。Stop フックが終了コード2で終わると、Claude は返事を止めずに続きを書き始めます。読み上げのためのスクリプトが会話の進み方を変えてはいけないので、エラーはログファイルに1行書くだけにしています。

もう1つ、読み上げは切り離して起動5します。長い返事は読み終わるまで数十秒かかり、上の設定の時間切れ(10秒)より長いからです。

入口の部分です(抜粋。テスト用の引数の処理を省いています)。

def main():
    # talk_mode.on が無ければ、標準入力も読まずに終わる
    if not os.path.exists(FLAG_PATH):
        return
    hook_input = json.loads(sys.stdin.read() or "{}")
    if hook_input.get("stop_hook_active"):
        return
    if not _flag_allows(hook_input.get("session_id")):
        return

    triggers, final = wait_for_turn(hook_input.get("transcript_path"))
    if final is None:
        log("最終返事が見つからない")
        return
    ok, why = should_speak(triggers)
    if not ok:
        log("読まない: %s" % why)
        return

    out = build_speech(final)
    if out:
        speak(out)
        log("読み上げ %d 文字(%s)" % (len(out), why))


def speak(text):
    subprocess.Popen(
        [TTS_PATH, "leda", text],
        start_new_session=True,      # フックが先に終わっても、読み上げは続く
        stdin=subprocess.DEVNULL,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        close_fds=True,
    )


if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        log("例外: %r" % (e,))
    os._exit(0)   # 何があっても終了コード0

3. 最後の返事を記録から取り出す

transcript_path のファイルは JSONL6 で、会話の出来事が1行に1つずつ書き足されていきます。簡略化するとこういう並びです(実際の行には、もっと多くの項目が入っています)。

{"type":"user","message":{"content":"さっきのテスト、どうなった?"}}
{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash"}]}}
{"type":"user","message":{"content":[{"type":"tool_result"}]}}
{"type":"assistant","message":{"content":[{"type":"text","text":"テストは12件、すべて通りました。"}]}}

ここから「最後の返事」を決める規則は3つです。

  • assistant の行は、text の部分だけを拾う。考えている途中の部分(thinking)とツールの呼び出しは読まない
  • tool_result(ツールの結果)は、こちらが打った文ではないので、指示としては数えない
  • 最後の指示より後ろにある text のうち、いちばん最後のものを「最後の返事」とする
def pick_turn(events):
    """最後のターンの(きっかけの本文たち, 最終返事の本文)を返す。返事が無ければ (triggers, None)"""
    last_user = None
    for i in range(len(events) - 1, -1, -1):
        if events[i][0] == "user":
            last_user = i
            break
    if last_user is None:
        return [], None

    # きっかけ=前のターンの返事より後ろにある user 行すべて
    start = 0
    for i in range(last_user - 1, -1, -1):
        if events[i][0] == "assistant":
            start = i + 1
            break
    triggers = [e[1] for e in events[start:last_user + 1] if e[0] == "user"]

    final = None
    for i in range(last_user + 1, len(events)):
        if events[i][0] == "assistant":
            final = events[i][1]
    return triggers, final

events は、JSONL を1行ずつ読んで ("user", 本文)・("assistant", 本文)・("tool", "") の並びにしたものです。

フックが呼ばれた瞬間に、最後の行がまだファイルに書かれていないことも考えられます。見つからなければ0.2秒おきに読み直し、1.5秒で打ち切る作りにしました。登録した9月26日 18:40 から9月27日までのログに、打ち切った記録はありません。

4. 読むターンと読まないターンを分ける

Stop フックは、こちらが何も言っていない回にも呼ばれます。前回の作りでは、iPhone の声を見張っている Monitor7 が期限切れになったときや、裏で動かした作業が終わったときにも Claude に通知が届き、Claude が返事を書くからです。ここで読み上げると、誰も話しかけていないのに声が出ます。

# きっかけ 読むか
1 キーボードで打った指示 読む
2 iPhone の声(【iPhone音声】 を含む通知) 読む
3 通知だけ(見張りの期限切れ・裏の作業の完了) 読まない
4 stop_hook_active が true 読まない

iPhone の声も通知として届くので、通知の中に目印の 【iPhone音声】 があれば読みます。それ以外は、通知の枠を取り除いて何も残らなければ「通知だけのターン」です。

VOICE_MARK = "【iPhone音声】"
_SYSREM_RE = re.compile(r"<system-reminder>.*?</system-reminder>", re.S)
_TASKNOTE_RE = re.compile(r"<task-notification>.*?</task-notification>", re.S)


def should_speak(triggers):
    """(読むか, 理由) を返す"""
    if not triggers:
        return False, "きっかけの入力が見つからない"
    joined = "\n".join(triggers)
    if VOICE_MARK in joined:
        return True, "iPhone音声"
    for t in triggers:
        rest = _SYSREM_RE.sub("", t)
        rest = _TASKNOTE_RE.sub("", rest)
        if "[SYSTEM NOTIFICATION" in rest:
            continue
        if rest.strip():
            return True, "文字入力"
    return False, "通知だけのターン"

5. 声に向く形に整える

画面向けの返事をそのまま読むと、表の縦棒やコードの記号まで声になります。整え方の決まりはこうしました。

# 画面の返事にあるもの 声にするとき
1 表 1行ごとに、セルを「、」でつないだ1文にする。見出しの下のハイフンだけの行は捨てる
2 箇条書き 行頭の記号を外して、文にする
3 コードブロック・URL・ファイルの場所 読まない。外したものがあれば、最後に「ユーアールエルやコードは、画面に出しました。」と足す
4 単語1つだけのインラインコード・/ で始まるコマンド名 記号を外して名前を読む
5 太字・見出し・絵文字 記号だけ外す
6 8/23 のような日付 8月23日 に直してから読む
7 3,000文字を超える返事 文の切れ目で切り、「以降は画面をご覧ください。」と足す

返事の最後に 🔊 で始まる段落を置いた場合は、その段落だけを読みます。調べものの報告のように、全部を聞く必要のない返事で使います。

この返事を通してみます。

✅ テストは12件、すべて通りました。

| 種類 | 結果 |
|---|---|
| 単体テスト | 10件 通過 |
| 結合テスト | 2件 通過 |

- 直したのは `app.py` の1か所です
- 詳しくは https://example.com/report に置きました
- 登録したフックは `/hooks` で確かめられます

```bash
pytest -q
```

**8/23 の記事**の手順3を作り直しました。

声に渡る文はこうなります。

テストは12件、すべて通りました。種類、結果。単体テスト、10件 通過。結合テスト、2件 通過。直したのは の1か所です。詳しくは に置きました。登録したフックは フックス で確かめられます。はちがつ23日 の記事の手順3を作り直しました。ユーアールエルやコードは、画面に出しました。

「直したのは の1か所です」のように、外したところが空白のまま残ります。このことは付録Cに書きました。

表の1行を文にする部分です。

def _table_row(self, line):
    body = line.strip()
    if body.startswith("|"):
        body = body[1:]
    if body.endswith("|"):
        body = body[:-1]
    cells = [self.inline(c) for c in body.split("|")]   # セルごとに記号を外す
    cells = [c for c in cells if c]
    if not cells:
        return ""
    return "、".join(c.rstrip("。") for c in cells) + "。"

6. 読み間違える語は辞書に分ける

Google の声は、Qiita や 御社 のような語を読み間違えます。読み方の直しはコードに書かず、JSON の辞書に分けました。辞書はフックが動くたびに読み直すので、足した語は次の返事から効きます。

{
  "語": {
    "Qiita": "キータ",
    "一区切り": "ひとくぎり",
    "御社": "おんしゃ"
  },
  "英字語": {
    "Claude Code": "クロードコード",
    "AI": "エーアイ",
    "URL": "ユーアールエル",
    "hooks": "フックス"
  },
  "数の読み_月": {
    "4月": "しがつ",
    "8月": "はちがつ",
    "9月": "くがつ"
  },
  "声から外す行": [
    "^\\s*iPhone音声の受け取りを(?:仕掛け直した|仕掛けた).*$"
  ]
}

左が画面の文字、右が声に渡す文字です。節の名前は自由で、節を増やしてもコードは変えません。私の辞書は、声で聞いて引っかかった語を足していった結果、いま152語あります。

置き換えには境目の決まりを2つ入れました。

  • 英字で始まる語は、前に英字が無いときだけ置き換える。OpenAI の中の AI は触らない
  • 数字で始まる語は、前に数字が無いときだけ置き換える。120万円 の中の 20万円 は触らない

声から外す行 は、正規表現8のリストです。合った行は、整える前に丸ごと消します。

7. 長い返事も Google の声で読めるように差し替える

前回の google_tts_say.sh は、受け取った文を1回で合成に送っていました。要点だけなら困らなかったのですが、返事を丸ごと渡すようになると、3つのことが困ります。

# 困ること 理由
1 長い返事の声が Kyoko に変わる Google Cloud Text-to-Speech に1回で送れるのは5,000バイト(日本語で1,600文字ほど)まで。超えると HTTP 400 が返り、予備の say -v Kyoko に切り替わる
2 最初の声が出るまで待たされる 全文の合成が終わるまで再生できない
3 2つの読み上げが重なる 前の返事を読んでいる間に次の返事が終わると、afplay が2本同時に動いて、声が重なる

差し替えた google_tts_say.sh です。

#!/bin/zsh
# Google Cloud Text-to-Speech で読み上げる
# 使い方: google_tts_say.sh <話者> <テキスト>
# 合成に失敗した分だけ、Mac 内蔵の say -v Kyoko で読む

SCRIPT_DIR="${0:A:h}"
SYNTH="$SCRIPT_DIR/google_tts_synth.sh"

# 前の読み上げが再生中なら止める(あとから来たほうを読む)
STATE="${TMPDIR:-/tmp}/gtts_current.pid"
if [[ -f "$STATE" ]]; then
  OLD=$(cat "$STATE" 2>/dev/null)
  if [[ -n "$OLD" && "$OLD" != "$$" ]] && kill -0 "$OLD" 2>/dev/null; then
    pkill -P "$OLD" 2>/dev/null   # 子(afplay・say・合成)を先に止める
    kill "$OLD" 2>/dev/null
  fi
fi
pkill -x afplay 2>/dev/null       # 親がいなくなって再生が続いている分も止める
echo $$ > "$STATE"
# 控えを消すのは、中身が自分の PID のときだけ(あとから来たものの控えを消さない)
trap '[[ "$(cat "$STATE" 2>/dev/null)" == "$$" ]] && rm -f "$STATE" 2>/dev/null' EXIT

VOICE_KEY="${1:-leda}"
shift
TEXT="$*"
[[ -z "$TEXT" ]] && { echo "ERROR: テキストが空" >&2; exit 1 }

# 文を分ける:1つ目は、文の切れ目で25文字を超えたら閉じる(最初の声を早く出すため)
# 2つ目からは400文字ずつ。最後の1つが20文字未満なら、1つ前にくっつける
CHUNK_STR=$(TEXT="$TEXT" python3 - <<'PYEOF'
import os, re
t = os.environ["TEXT"].replace("\n", " ")
sents = [s for s in re.findall(r"[^。!?]*[。!?]?", t) if s.strip()]
chunks, buf = [], ""
cur_limit = 25
for s in sents:
    buf += s
    if len(buf) >= cur_limit:
        chunks.append(buf)
        buf = ""
        cur_limit = 400
if buf:
    chunks.append(buf)
if len(chunks) >= 2 and len(chunks[-1]) < 20:
    chunks[-2] += chunks[-1]
    chunks.pop()
print("\x1f".join(chunks))
PYEOF
)
typeset -a CHUNKS
CHUNKS=("${(@ps.\x1f.)CHUNK_STR}")

play_or_say() {  # $1=mp3のパス $2=元の文
  if [[ -n "$1" && -s "$1" ]]; then
    afplay "$1"
    rm -f "$1"
  else
    echo "WARN: Google TTS 失敗。Kyoko で読み上げます" >&2
    say -v Kyoko "$2"
  fi
}

N=${#CHUNKS}
MP3=$("$SYNTH" "$VOICE_KEY" "${CHUNKS[1]}")
i=1
while (( i <= N )); do
  # いまの1つを再生している間に、次の1つを裏で合成する
  if (( i < N )); then
    NEXT_OUT=$(mktemp -t gtts_next)
    ( "$SYNTH" "$VOICE_KEY" "${CHUNKS[i+1]}" > "$NEXT_OUT" 2>/dev/null ) &
    NEXT_PID=$!
  fi
  play_or_say "$MP3" "${CHUNKS[i]}"
  if (( i < N )); then
    wait $NEXT_PID
    MP3=$(cat "$NEXT_OUT" 2>/dev/null)
    rm -f "$NEXT_OUT"
  fi
  (( i++ ))
done

400文字は、日本語で1,200バイトほどです。5,000バイトの上限まで余裕があります。

pkill -x afplay は、この読み上げ以外で再生している afplay も止めます。ほかの用途で afplay を使っているなら、この1行は外してください。

合成側の google_tts_synth.sh にも1か所足します。表を文にすると「、」でつながった長い1文ができ、声の種類が Chirp 3: HD9 のときは、1文が長すぎても HTTP 400(sentences that are too long)が返ります。長い文は読点のところで句点に置き換えて割り、文と文を改行で区切って送ります。

import re

def split_long_sentences(text, limit=200):
    out = []
    for sent in re.findall(r"[^。!?\n]*[。!?\n]?", text):
        if not sent:
            continue
        if len(sent.encode()) <= limit:
            out.append(sent)
            continue
        buf = ""
        for part in re.findall(r"[^、]*、?", sent):
            if buf and len((buf + part).encode()) > limit:
                out.append(buf.rstrip("、") + "。")
                buf = part
            else:
                buf += part
        if buf:
            out.append(buf)
    return "\n".join(s.strip() for s in out if s.strip())

前回の google_tts_synth.sh の中の Python で、"input": {"text": os.environ["TEXT"]} を "input": {"text": split_long_sentences(os.environ["TEXT"])} に変えます。

8. 動かす

8-1. CLAUDE.md の1行を消す

1章の1行は消します。残しておくと、Claude が自分で呼んだ読み上げの途中で、フックの読み上げが始まります。7章の差し替えで、あとから来たほうが前のものを止めるので、Claude の声は途中で切れます。

8-2. 読み上げのオン・オフ

talk_mode.on というファイル1つで切り替えます。

# オン(開いている全部のセッションで読む)
touch ~/Library/Scripts/talk_mode.on

# オン(1つのセッションだけで読む)
echo "セッションID" > ~/Library/Scripts/talk_mode.on

# オフ
rm ~/Library/Scripts/talk_mode.on

Claude Code を2つ以上開いているとき、talk_mode.on が空なら全部のセッションが読みます。7章の差し替えのとおり、あとから終わった返事が前の声を止めるので、聞きたい返事が途中で切れることがあります。聞きたいセッションが決まっているなら、talk_mode.on にセッションIDを1行書いてください。セッションIDは、会話の記録のファイル名と同じです。いま作業しているセッションの記録は、たいてい一番新しいファイルです。

ls -t ~/.claude/projects/*/*.jsonl | head -1
# → /Users/あなたのユーザー名/.claude/projects/…/0f3c1d2e-….jsonl
#   「.jsonl」の前がセッションID

talk_mode.on が無いとき、フックは標準入力も読まずに終わります。オフの間に Stop フックが呼ばれても、待たされることはありません。

8-3. 声を出さずに確かめる

TALK_DRY_RUN=1 を付けると、読み上げずに、声に渡るはずの文を画面に出します。talk_mode.on があるかどうかも見ません。

# 返事の文をファイルに書いて、整え方だけ確かめる
TALK_DRY_RUN=1 /usr/bin/python3 ~/Library/Scripts/talk_stop_hook.py --text-file reply.md

# 本物の会話の記録から、最後の返事を取り出すところまで確かめる
TALK_DRY_RUN=1 /usr/bin/python3 ~/Library/Scripts/talk_stop_hook.py --transcript ~/.claude/projects/…/0f3c1d2e-….jsonl

読まないターンのときは (読まない: 通知だけのターン) のように理由が出ます。5章の例は、--text-file で出したものです。

8-4. ログを見る

声が出ないときは、まずログを見ます。

tail ~/Library/Logs/talk_stop_hook.log
# 2026-09-26 18:39:57 読み上げ 217 文字(文字入力)
# 2026-09-26 18:53:46 読み上げ 423 文字(iPhone音声)

「読み上げ」の行があって声が出ていなければ、google_tts_say.sh の側を疑います。前回の記事の「ここまでを確かめる」と同じく、google_tts_say.sh leda "聞こえていますか" を単体で打てば切り分けられます。

8-5. 読み上げを途中で止める

返事を丸ごと読むようになると、途中で止めたい場面が増えます。長い表を読み始めたときや、画面を見れば足りる返事のときです。止めるスクリプトを1本作り、Mac のショートカット.app からキーに割り当てました。

#!/bin/zsh
# 読み上げを止める(~/Library/Scripts/tts_stop.sh)
pkill -f "google_tts_say.sh"
pkill -f "google_tts_synth.sh"
pkill -x afplay
pkill -x say
setopt null_glob   # 一致するファイルが無くてもエラーにしない
rm -f "${TMPDIR:-/tmp}"/gtts_next*
rm -f "${TMPDIR:-/tmp}/gtts_current.pid"
exit 0

chmod +x ~/Library/Scripts/tts_stop.sh で実行できるようにしてから、ショートカット.app に登録します。

  1. 新しいショートカットを作り、「シェルスクリプトを実行」を1つ置く。中身は $HOME/Library/Scripts/tts_stop.sh の1行だけ
  2. 右上の ⓘ(ショートカットの詳細)を開き、キーボードショートカットに ⇧⌘Space を登録する

読み上げの途中で ⇧⌘Space を押すと、声がその場で止まります。押してから止まるまで数秒かかるのは、ショートカット.app が裏で起動する最初の1回だけです。

VS Code のターミナルで Claude Code 2.1.280 を使うと、Claude Code の入力欄にカーソルがあるときだけ、⇧⌘Space を押しても入力欄にスペースが1つ入るだけで声は止まりません。エディタやファイル一覧など、入力欄の外を1回クリックしてから押せば止まります。9月22日まで使っていた 2.1.226 のときは、この症状は出ていませんでした。

Claude Code 2.1.269 の変更履歴には、名前を知らないターミナルでも、kitty キーボード方式10の問い合わせに答えればその方式を使う、という変更が載っています。VS Code 1.139.1 では、設定 terminal.integrated.enableKittyKeyboardProtocol の最初の値が true なので、使えると返します。この値は、VS Code 本体に入っている設定の定義で確かめました。この方式では ⌘ を含むキーも Claude Code へ送られるので、ショートカット.app まで届きません。

私は、入力欄の外をクリックしてから押す使い方で済ませています。入力欄にカーソルがあるままでも止めたいなら、VS Code の settings.json でこの設定を false にするか、止めるキーを別の組み合わせに変えます。どちらも私は試していません。確かめたのも VS Code のターミナルだけです。

9. 前の2本の記事から変わったところ

ここからは「行き」の話です。前々回の記事(iPhone から Mac へ声を届ける)と前回の記事から、4か所が変わりました。

なお、前々回の記事の voice_server.py・voice_tail.sh・voice.log などは、前回の記事から頭に iphone_ を付けた名前にしています。中身の役割は同じです。

9-1. 送り先を IP から Mac の名前に変えた

前回までは、iPhone のショートカットの送り先に Mac の IP(http://192.168.1.20:9100 のような形)を書いていました。ルーターが番号を配り直すたびに届かなくなり、そのたびにショートカットを直していました。

いまは Mac の名前で送っています。

# Mac の名前を調べる
scutil --get LocalHostName
# 例: my-mac

# 名前で届くか確かめる
curl -s http://my-mac.local:9100
# → iPhone voice server is running

ショートカットの「URLの内容を取得」の URL を http://my-mac.local:9100 に変えるだけです。.local の名前11は IP が変わっても変わらないので、前々回の記事の「Mac の番号は変わることがあります」は気にしなくてよくなりました。同じ Wi-Fi の中でしか通じない点は、IP のときと同じです。

9-2. iPhone の音声入力は、2分ほどで切れる

前々回の記事で「iPhone の音声入力はよく粘ってくれます」と書きましたが、話し続けると2分ほどで切れました。届いた文の長さはばらばらで、文字数で切れているわけではありません。ショートカットの「聞き取りを停止」は「タップ時」、iPhone の自動ロックは「なし」になっていたので、残ったのは iOS の音声入力そのものの時間の上限です。設定では外せません。

そこで、ショートカットを1本足しました。短い話は今までの「テキストを音声入力」、長い話は録音してから文字にする2本目、と使い分けています。2本目は iOS 26 のショートカットでこう組みます。

  1. 「オーディオを録音」:止めるまで録り続ける
  2. 「テキストに文字起こし」:録った音声を文字にする(iPhone の中で処理される)
  3. 「URLの内容を取得」:方法は POST、本文を要求は「ファイル」、ファイルは変数「文字起こしされた音声」

Mac の側は何も変えていません。私は2と3の間に「クリップボードにコピー」も挟んでいます。

組むときに間違えやすいところが3つあります。

  • 2のアクションは「オーディオを文字起こし」で検索しても出ない。「音声を文字起こし」で検索すると出て、置いたあとは「テキストに文字起こし」と表示される
  • アクションの中の薄い青の札(「オーディオファイル」など)は、上のアクションの結果が入っているのではなく、空の状態。濃い青の札になり、アクション同士が線でつながれば通っている
  • ファイルを入れる欄は、タップするとファイルを選ぶ画面へ行く。変数を入れるときは長押しして「変数を選択」を選び、上のアクションをタップする

録音の形では、話している最中に文字が画面に出ません。言い直しが多い日は、1本目のほうが使いやすいです。

9-3. 見張りは、声が30分届かないと切れる

前回の記事で「見張りは persistent: true(セッションが続く限り有効)で立つので、長くソファーにいても切れる心配はありません」と書きましたが、実際には切れました。30分のあいだ声が1本も届かないと、Claude にこういう通知が来ます。

[Monitor expired after 30m with no events delivered. Re-arm it if you still need the watch — and widen the filter if silence was unexpected.]

SessionStart フックで渡している timeout_ms: 3600000(60分)を付けても、切れるまでの時間は30分でした。

いまは、切れたら Claude が同じコマンドで見張りを立て直し、画面には1行だけ返す、と決めています。同じようにするなら、CLAUDE.md にこう書いておけば足ります。

- iPhone音声の見張り(Monitor)が期限切れで止まったら、同じコマンドで立て直すこと。
  返事は「iPhone音声の受け取りを仕掛け直した」の1行だけでよい

この立て直しのターンは通知だけがきっかけなので、4章の決まりでフックは読みません。30分ごとに声が出ることはありません。

9-4. 受け口が、届いた文をクリップボードにも入れる

iphone_voice_server.py に、受け取った文を Mac のクリップボードへ入れる処理を足しました。

import subprocess

def to_clipboard(text):
    try:
        subprocess.run(["/usr/bin/pbcopy"], input=text.encode("utf-8"),
                       timeout=3, check=False)
    except Exception:
        pass  # クリップボードに入らなくても、受け取りそのものは止めない

do_POST の中で、print("【iPhone音声】" + …) の直後に to_clipboard(…) を呼んでいます。

見張りは、手前の VS Code のウィンドウが担当のフォルダのときだけ声を通します。別のウィンドウを手前にしたまま話すと、その声はログには残りますが Claude には届きません。クリップボードに入っていれば、ウィンドウをクリックして Cmd+V で貼り直せます。その代わり、話すたびにクリップボードの中身が上書きされます。


付録A:かかったお金

増えたお金は、Google Cloud Text-to-Speech で読ませる文字数の分だけです。

# 項目 値
1 フックが読んだ回数(9月26日 18:39〜18:56) 8回
2 読んだ文字数の合計 3,040文字
3 1回あたり 平均380文字
4 Chirp 3: HD の無料枠 月100万文字
5 無料枠を超えた分 100万文字あたり US$30

料金は、2026年9月26日に公式の料金表で確かめた値です。

前回は Claude が選んだ要点だけを読んでいたので、1回に読む文字は今より少なめでした。返事を丸ごと読むようになったので、文字数は増えます。1回380文字で計算すると、無料枠の100万文字に届くのは1日に約87回読ませた場合です。

私の9月分は、26日の時点で448,914文字でした。声の会話のほかに、長い文の読み上げにも使っていての数字です。前回の記事に書いた google_tts_usage.log を、ときどき足し算しておくと安心です。

awk '$1 ~ /^2026-09/ {s+=$2} END {print s}' ~/Library/Scripts/google_tts_usage.log

付録B:かかった時間

作るのは Claude Code に任せ、モデルは Fable を指定しました。

# 時刻 できたこと
1 18:05 「読み上げを、プログラムで必ず動くようにできないか」と考えた
2 18:09 Claude Code に作り始めさせた
3 18:34 talk_stop_hook.py と辞書の2ファイルができた
4 18:40 settings.json に登録し、最初の返事が声で出た
5 18:42〜18:55 付録Cの1〜3を直した

考えてから最初の声が出るまで35分、直しまで入れて50分でした。

動いているときの時間はこうです。

# 測ったところ 時間
1 talk_mode.on が無いときに、フックが終わるまで 0.06秒
2 記録が38MB(6,237行)のセッションで、最後の返事を取り出して整えるまで 0.4秒
3 読み上げを起動してから、最初の声が出るまで 2秒前後(最初の25文字ほどの合成。1.5〜3.3秒)

最初の声までの時間は、ほとんどが Google 側の合成です。フックは記録のファイルを毎回頭から読み直していますが、長く続けたセッションでも0.4秒で済んでいます。

付録C:つまずいたところ

1. / で始まる言葉が読まれなかった

/hooks のようなコマンド名が、声から抜けていました。ファイルの場所を外す規則が、/ で始まる語を全部ファイルの場所として扱っていたためです。英字で始まる単語1つで、後ろに / や . が続かないものはコマンド名とみなし、/ だけ外して読むように直しました。単語1つのインラインコードも読むようにしています。

2. 見張りを立て直した1行まで読まれた

9-3の立て直しは、通知だけのターンなら読みません。ところが、こちらが文字を打ったターンの返事の中に、立て直しの1行が混ざることがありました。これは読む必要のない行なので、6章の辞書の 声から外す行 に正規表現で入れました。外したい行が増えたら、辞書に足すだけで済みます。

3. 「8/23」が「23分の8」と読まれた

Google の声は、8/23 を分数として読みます。月と日の組み合わせに合う 数字/数字 は、整えるときに 8月23日 に書き換えるようにしました。書き換えたあとに辞書の 数の読み_月 が効き、「はちがつ」と読みます。年つきの 2026/9/26 も 2026年9月26日 にしています。

4. インラインコードを外すと、文に空白が残る

5章の例の「直したのは の1か所です」がそれです。ファイル名を外したところが空白で残ります。最後に「ユーアールエルやコードは、画面に出しました。」と足しているので、聞いていて画面を見ればよいことは分かります。空白そのものは、まだ直していません。

5. 声で「あとで画面に出します」と言っていた

これは作り直す前の話です。声の文を Claude が画面とは別に書いていたので、画面にもう出ているものを「あとで出します」と言っていました。今は画面の返事から声を作るので、この食い違いは起きません。フックが最後に足す一言も「画面に出しました」と過去形にしてあります。

6. 止めるキーが、Claude Code の入力欄でだけ効かなくなった

9月の半ばまで止まっていた ⇧⌘Space が、VS Code で Claude Code に話しかけている最中だけ効かなくなりました。押すと、入力欄にスペースが1つ入ります。

キーがショートカットまで届いたかは、ショートカット.app の実行記録で分かります。記録は ~/Library/Shortcuts/Shortcuts.sqlite の ZSHORTCUTRUNEVENT 表にあり、キーで動いた回は services-keyboard-shortcut と残ります。ショートカット.app が開いている DB を直接読まないよう、-wal・-shm を含む3つのファイルを一時フォルダにコピーしてから見ました。

d=$(mktemp -d); cp ~/Library/Shortcuts/Shortcuts.sqlite* "$d"/
sqlite3 "$d/Shortcuts.sqlite" "select datetime(ZDATE+978307200,'unixepoch','localtime'), ZSOURCE from ZSHORTCUTRUNEVENT order by ZDATE desc limit 5"

978307200 は、ZDATE の起点(2001年1月1日)を UNIX 時刻に直すための足し算です。

キーでの実行は、9月10日から18日までの18回が最後でした。カーソルの場所を変えて、押し比べた結果です。

# カーソルがある場所 ⇧⌘Space を押した結果
1 Chrome 止まった
2 VS Code の、Claude Code を動かしていないターミナルのタブ 止まった
3 VS Code の、Claude Code の入力欄 止まらない。入力欄にスペースが入る

VS Code 全体ではなく、Claude Code の入力欄でだけ起きています。会話の記録は各行に Claude Code の版(version)を持っているので、日付ごとに並べました。9月22日までは 2.1.226、9月23日からは 2.1.280 です。間の 2.1.269 の変更履歴に、名前を知らないターミナルでも kitty キーボード方式の問い合わせに答えれば使う、という変更が載っていました。直し方は8-5のとおりです。

関連記事

  1. Stop フック。Claude が1回の返事を書き終えたときに呼ばれる hooks の1つ。呼ばれたスクリプトが終了コード2で終わると、Claude は止まらずに続きを書き始めます。 ↩

  2. hooks。Claude Code が決まった場面で自分のスクリプトを呼ぶ機能。セッションの開始(SessionStart)、ツールを使う前(PreToolUse)、返事を書き終えたとき(Stop)などがあり、~/.claude/settings.json に書きます。 ↩

  3. セッションID。Claude Code の会話1本ごとに付く ID。会話の記録のファイル名(.jsonl の前)と同じ文字列です。 ↩

  4. 会話の記録(transcript)。Claude Code がセッションごとに残す、会話の全文のファイル。~/.claude/projects/<作業フォルダから作った名前>/<セッションID>.jsonl に保存されます。 ↩

  5. 切り離して起動。Python の subprocess.Popen に start_new_session=True を付けると、起動したコマンドの終わりを待たずに次の行へ進みます。フックのスクリプトが先に終わっても、読み上げは止まりません。 ↩

  6. JSONL。1行に JSON を1つずつ書いたファイルの形式。行を書き足していく記録に向いていて、1行ずつ読めば途中の行だけでも中身が分かります。 ↩

  7. Monitor。Claude Code のツールの1つ。指定したコマンドが標準出力に1行出すたびに、その行を通知として会話に届けます。前回の記事で、iPhone の声を受け取るのに使いました。 ↩

  8. 正規表現。文字の並びの形を記号で書いて、合う部分を探す書き方。^ は行の頭、.* は「どんな文字でも0文字以上」を表します。 ↩

  9. Chirp 3: HD。Google Cloud Text-to-Speech の声の種類の1つ。前回の記事から使っている leda(ja-JP-Chirp3-HD-Leda)もこの種類です。 ↩

  10. kitty キーボード方式。押されたキーを、Shift・Ctrl・⌘ などの組み合わせまで区別してアプリへ送る、ターミナルの方式。kitty というターミナルが始めたので、この名前で呼ばれます。アプリが問い合わせ、ターミナルが使えると返したときだけ切り替わります。 ↩

  11. .local の名前。同じネットワークの中だけで通じる、機器の名前。Mac の名前は「システム設定 → 一般 → 共有」の一番下にある「ローカルホスト名」で見られます。 ↩

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?