iPhoneに話しかけて Mac の Claude Code に指示を出し、返事は Google Cloud Text-to-Speech の声で聞く
ソファーで本を読みながら、手元のiPhoneにひとこと。
「さっきのテスト、どうなった?」
数秒後、部屋の向こうのMacから落ち着いた声が返ってきます。「テストは12件、すべて通りました」。この間、私は本から目を離していません。キーボードにも触っていません。
Claude Code の操作は「目で画面を追って、手でキーを打つ」ものだと思い込んでいました。ところが、入力を口に、出力を耳に渡してみたら、普通に成立してしまった。今日はその作り方の話です。すごい発明の話ではなく、「こういう使い方もあるんだ」くらいに読んでもらえたらうれしいです。
できあがるもの
- iPhoneに話しかけると、その内容がMacのClaude Codeに「今こちらが言ったこと」として届き、Claudeがそのまま動き出す
- Claudeの返事(の要点)が、Google Cloud Text-to-Speech の自然な声でスピーカーから流れる
「話しかける」側の仕組みは、以前この記事に書きました。
当時は返事を読むためにMacの前へ戻っていました。せっかく声で送れるのに、受け取りは目。今回「返事を声で聞く」側を作って、往復とも音になりました。この記事1本だけで組めるように、送る側の手順もまとめ直してあります。
仕組みの全体像
データの通り道は一本です。行き(声が届く)と帰り(声で返る)に分けて描くとこうなります。
登場するスクリプトは7本ありますが、役割は4つしかありません。
| 役割 | ファイル | 仕事 |
|---|---|---|
| 受け口 | iphone_voice_server.py | iPhoneからのPOSTを受けて、ログに1行書く |
| 受け渡し | iphone_voice_hook.sh iphone_voice_tail.sh |
届いた1行を、正しいClaude Codeセッションに通す |
| 読み上げ | google_tts_synth.sh google_tts_say.sh google_tts_queue.sh |
テキストをGoogleの声にして再生する |
| 音声入力の維持 | dictation_keepalive.sh | Mac側の音声入力が勝手に落ちるのを、起こして回る |
以降の章は、この表を上から順に組んでいくだけです。
準備するもの
| もの | 補足 |
|---|---|
| Mac | Claude Code は VS Code のターミナルで動かします(あとで出てくるウィンドウ判定が VS Code 前提のため) |
| iPhone | 標準の「ショートカット」アプリだけ。追加アプリなし |
| 同じWi-Fi | MacとiPhoneが同じネットワークにいること |
| Google Cloud | アカウントと gcloud CLI(導入は公式手順から)。やることは認証・プロジェクト選択・Text-to-Speech API の有効化の3つ。⚠ 無料枠の範囲でも課金アカウント(カード登録)は必要です |
Macのアドレス(ローカルIP)はあとで使うので、先に調べておきます。
ipconfig getifaddr $(route -n get default | awk '/interface:/{print $2}')
# 例: 192.168.1.20
en0 決め打ちのコマンドをよく見かけますが、en0 が Wi-Fi とは限らないので、「今つながっている口」を自動で選ぶ上の形が安全です。
受け口のサーバは認証なしのHTTPです。自宅のWi-Fi限定で使ってください。同じネットワークにいる人は誰でも送り込めるので、カフェや公衆Wi-Fiでこの構成のまま動かすのはやめておきましょう。
手順1/iPhoneの声をMacに届ける
受け口を置く
Python標準ライブラリだけの小さなHTTPサーバです。~/Library/Scripts/iphone_voice_server.py に置きます。
#!/usr/bin/env python3
"""iPhone のショートカットから送られた音声入力テキストを受け取って標準出力に出す。
iCloud を通らないので、同じ Wi-Fi にいれば1秒かからず届く。
"""
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
PORT = 9100 # 他で使っていない番号なら何でもよい
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length).decode("utf-8", errors="replace").strip()
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write("OK".encode())
if body:
print("【iPhone音声】" + " ".join(body.splitlines()), flush=True)
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write("iPhone voice server is running".encode())
def log_message(self, *args):
pass # アクセスログは出さない(通知が増えるため)
if __name__ == "__main__":
try:
HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
except OSError as e:
print(f"起動できない: {e}", flush=True)
sys.exit(1)
届いた文の先頭に 【iPhone音声】 という目印を付けて1行出力する——仕事はこれだけです。この目印は、あとで見張り側が「これは声だ」と見分けるための旗になります。
Macが起きている間ずっと動かす
起動はLaunchAgentに任せます。読み込めば自動で立ち上がるので、手で python3 を実行しておく必要はありません。~/Library/LaunchAgents/com.example.iphone-voice.plist を作ります。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.example.iphone-voice</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/あなたのユーザー名/Library/Scripts/iphone_voice_server.py</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/Users/あなたのユーザー名/Library/Logs/iphone_voice.log</string>
<key>StandardErrorPath</key><string>/Users/あなたのユーザー名/Library/Logs/iphone_voice.log</string>
</dict>
</plist>
標準出力をログファイルへ流しているのがポイントです。サーバが print した1行が、そのまま iphone_voice.log の1行になります。読み込みはこう。
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.iphone-voice.plist
plist を直したときは、launchctl bootout gui/$(id -u)/com.example.iphone-voice で一度外してから、もう一度読み込みます。もうひとつ、離席が前提の仕組みなので、Macがスリープすると受け口ごと止まります。システム設定 → ディスプレイの詳細設定で「電源アダプタ接続時はスリープさせない」にしておくと安心です。
ここまでを確かめる
iPhoneを触る前に、Macの中だけで一往復させます。
curl -s -X POST --data '聞こえますか' http://localhost:9100
# → OK
tail -1 ~/Library/Logs/iphone_voice.log
# → 【iPhone音声】聞こえますか
ログに目印付きの1行が出ていれば、受け口は完成です。
iPhoneのショートカットを組む
ショートカットアプリで新規作成し、アクションを2つ並べます。
- 「テキストを音声入力」——しゃべった内容がテキストになる
- 「URLの内容を取得」——そのテキストをMacへ送る。置いただけではURL欄しか見えないので、アクション右下の下向き矢印をタップして詳細を開き、こう設定します
| 欄 | 入れるもの |
|---|---|
| URL |
http://192.168.1.20:9100(さっき調べた自分のMacのアドレスに置き換え) |
| 方法 | POST |
| 本文を要求 | ファイル(⚠ 「テキスト」ではありません。「ファイル」を選ぶと変数の中身がそのまま本文として送られます) |
| ファイル | 変数「音声入力されたテキスト」(手で打たず、キーボード上の候補から選ぶ) |
初回実行時に「ローカルネットワーク上のデバイスへのアクセス」を聞かれるので許可してください。断ると黙って失敗し続けます。
できたショートカットはホーム画面に置くか、背面タップに割り当てると、ソファーから一動作で呼べます。iPhoneに向かってしゃべり、Macのログに増えた1行を確認できたら、行きの配線は開通です。
手順2/Claude Codeに拾わせる
ログに届いた声を、今度はClaude Codeが読みにいきます。
Claude Codeには Monitor という道具がある
Claude Code は、指定したスクリプトの出力を見張って、1行出るたびに「通知」として受け取れます(Monitorツール)。つまり「このログを見張れ」とClaudeに伝えれば、iPhoneの声がClaudeへの発言として届くようになります。自分の環境で使えるかどうかは、Claude Code に「Monitorツールは使える?」と聞いてしまうのが一番早いです。
毎回手で頼むのは面倒なので、セッション開始時に自動で伝わるようにします。Claude Code の hooks(決まったタイミングでスクリプトを呼ぶ仕組み)の SessionStart を使います。
~/.claude/settings.json に追記:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "/Users/あなたのユーザー名/Library/Scripts/iphone_voice_hook.sh"
}
]
}
]
}
}
すでに settings.json に中身がある場合は、この "hooks" の部分だけを既存のJSONの中へ合流させてください(ブロックごと末尾に貼り足すとJSONとして壊れます)。ファイルがまだ無ければ、この内容そのままで新規作成できます。
呼ばれる iphone_voice_hook.sh はこれです。
#!/bin/bash
# Claude Code の SessionStart hook から呼ぶ。
# 今のセッションの作業フォルダ名を調べて、「その名前で見張りを立てろ」と Claude に伝える。
FOLDER=$(basename "$PWD")
MSG="iPhoneから音声入力されたテキストを受け取れるようにするため、このターンの最初に Monitor ツールで次を仕掛けること(persistent: true, timeout_ms: 3600000):/Users/あなたのユーザー名/Library/Scripts/iphone_voice_tail.sh ${FOLDER} 。これは手前にあるVS Codeのウィンドウが「${FOLDER}」のときだけ声を拾う仕組みで、他のセッションとの取り合いを防いでいる。説明は「iPhone音声の受け取りを仕掛けた(${FOLDER})」の1行だけにして、通常の作業を続けること。"
python3 -c "
import json, sys
msg = sys.argv[1]
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'SessionStart',
'additionalContext': msg,
}
}, ensure_ascii=False))
" "$MSG"
セッションが立ち上がると、Claudeが自分で見張りを立てます。Claudeが自分の耳を自分で用意する、という構図です。見張りは persistent: true(セッションが続く限り有効)で立つので、長くソファーにいても切れる心配はありません。
複数セッションの取り合いを防ぐ
ここがこの仕組みでいちばん考えた場所です。Claude Code を2つ開いていると、同じログを2人が見張ることになり、ひとつの声を両方が拾ってしまいます。両方の部屋に同じ指示が飛ぶのは事故のもとです。
そこで見張り本体の iphone_voice_tail.sh は、手前に出ているVS Codeのウィンドウ名を確認してから声を通します。
#!/bin/bash
# iPhone から届いた音声入力テキストを、手前にある VS Code のフォルダに合うときだけ出す。
#
# 使い方: iphone_voice_tail.sh <フォルダ名>
FOLDER="$1"
LOG="/Users/あなたのユーザー名/Library/Logs/iphone_voice.log"
if [ -z "$FOLDER" ]; then
echo "使い方: $0 <フォルダ名>"
exit 1
fi
front_window() {
# 最前面アプリが VS Code のときだけ、その手前のウィンドウ名を返す
osascript <<'AS' 2>/dev/null
tell application "System Events"
set frontApp to name of first process whose frontmost is true
if frontApp is not "Code" then return ""
tell process "Code"
try
return name of (first window whose value of attribute "AXMain" is true)
on error
return ""
end try
end tell
end tell
AS
}
tail -n 0 -f "$LOG" | while IFS= read -r line; do
case "$line" in
*"【iPhone音声】"*) ;;
*) continue ;;
esac
win=$(front_window)
case "$win" in
*"$FOLDER"*) echo "$line" ;;
*) : ;; # 手前が別フォルダ・別アプリなら黙る
esac
done
ルールは単純で、しゃべる直前にクリックしたウィンドウのセッションが拾う。ソファーへ行く前に、話したい相手のウィンドウを一度クリックしておくだけです。VS Codeのウィンドウ名に作業フォルダ名が入ることを利用しています。
ここまでを確かめる
Claude Code のセッションを開き直して、iPhoneにひとこと。Claudeの画面に 【iPhone音声】…… が届いて、Claudeが返事をしたら、行きは全部つながりました。
届かないときは、macOSの許可を2つ疑ってください。ひとつはファイアウォール(python3 の着信許可)。もうひとつは、見張りが osascript でウィンドウ名を読むための「System Events を制御する」許可で、初回に出る確認を断っていると、声は黙って捨てられます(エラーは出ません)。システム設定 → プライバシーとセキュリティ → オートメーション で戻せます。
手順3/返事を声で返す(Google Cloud Text-to-Speech)
なぜMac内蔵の say ではないのか
Macには最初から読み上げがあります。say -v Kyoko こんにちは で日本語もしゃべる。最初はこれで済ませるつもりでした。
ただ、長い返事を聞いていると、どうにも機械っぽい。抑揚が平らで、聞き流すには耳が疲れます。画面を見ない運用では声が唯一の出力なので、ここはケチらないことにして、Google Cloud Text-to-Speech の Chirp3-HD という声に乗り換えました。こちらは人が読んでいるとしか思えない自然さで返してきます。say は、ネットワークが切れたときの予備役に回ってもらいました。
Google Cloud側の準備
# 認証(ブラウザが開きます)
gcloud auth login
# 使うプロジェクトを選ぶ(IDの一覧は gcloud projects list で見られます)
gcloud config set project あなたのプロジェクトID
# Text-to-Speech API を有効化
gcloud services enable texttospeech.googleapis.com
「プロジェクト」は Google Cloud 側の作業場の単位で、最初にコンソールで1つ作ると、そのときにIDが決まります。
料金の心配は、無料枠が月100万文字あります。私が組み上げた日に面白がって鳴らし続けた結果が3,360文字でした。毎日6,000文字読ませても月18万文字。個人の返事読み上げで枠を使い切るのは、なかなかの大事業です。それでも心配は残るので、読み上げた文字数をスクリプトが毎回ログに残す作りにしてあります(後述)。枠を超えたときの単価は公式の料金表で確認してください。
合成する部品と、しゃべる部品を分ける
スクリプトは3本に分かれています。まず合成だけをする google_tts_synth.sh。
#!/bin/zsh
# Google Cloud Text-to-Speech で音声ファイルだけを作る(再生はしない)。
# 標準出力に、できた mp3 のファイルパスを1行だけ返す。
# 使い方: google_tts_synth.sh <話者> <テキスト>
VOICE_KEY="${1:-leda}"
shift
TEXT="$*"
[[ -z "$TEXT" ]] && { echo "ERROR: テキストが空" >&2; exit 1 }
case "$VOICE_KEY" in
leda) VOICE="ja-JP-Chirp3-HD-Leda" ;;
charon) VOICE="ja-JP-Chirp3-HD-Charon" ;;
aoede) VOICE="ja-JP-Chirp3-HD-Aoede" ;;
kore) VOICE="ja-JP-Chirp3-HD-Kore" ;;
*) VOICE="$VOICE_KEY" ;;
esac
GCLOUD="$HOME/google-cloud-sdk/bin/gcloud" # which gcloud で出た場所に合わせる(Homebrew 導入だと別の場所)
PROJECT="あなたのプロジェクトID"
MP3=$(mktemp -t gtts).mp3
# トークンは50分キャッシュする(毎回 gcloud を起動すると数秒待たされるため)
TOKEN_CACHE="${TMPDIR:-/tmp}/gtts_token_cache"
if [[ -f "$TOKEN_CACHE" && -n "$(find "$TOKEN_CACHE" -mmin -50 2>/dev/null)" ]]; then
TOKEN=$(cat "$TOKEN_CACHE")
else
TOKEN=$("$GCLOUD" auth print-access-token 2>/dev/null) || { echo "ERROR: gcloud認証失敗" >&2; exit 2 }
(umask 077; echo "$TOKEN" > "$TOKEN_CACHE")
fi
TEXT="$TEXT" VOICE="$VOICE" TTS_TOKEN="$TOKEN" TTS_PROJECT="$PROJECT" python3 - <<'PYEOF' > "$MP3"
import json, base64, os, sys, urllib.request
body = json.dumps({
"input": {"text": os.environ["TEXT"]},
"voice": {"languageCode": "ja-JP", "name": os.environ["VOICE"]},
"audioConfig": {"audioEncoding": "MP3"},
}).encode()
req = urllib.request.Request(
"https://texttospeech.googleapis.com/v1/text:synthesize",
data=body,
headers={
"Authorization": "Bearer " + os.environ["TTS_TOKEN"],
"Content-Type": "application/json",
"x-goog-user-project": os.environ["TTS_PROJECT"],
},
)
with urllib.request.urlopen(req, timeout=30) as r:
d = json.load(r)
sys.stdout.buffer.write(base64.b64decode(d["audioContent"]))
PYEOF
if [[ ! -s "$MP3" ]]; then
echo "ERROR: 音声合成失敗" >&2
rm -f "$MP3"
exit 3
fi
echo "$(date +%Y-%m-%d) ${#TEXT} $VOICE" >> "$HOME/Library/Scripts/google_tts_usage.log"
echo "$MP3"
やっていることは REST API を1回呼ぶだけですが、小ワザが2つ入っています。
-
アクセストークンを50分キャッシュする。アクセストークンは、いまの認証から発行される「60分だけ有効な合鍵」です。発行する
gcloud auth print-access-tokenに毎回数秒かかり、声が出るまでの待ちに直撃するので、寿命より短い50分までは使い回す -
読み上げた文字数をログに記録する。無料枠の100万文字にどれだけ近づいたか、いつでも
awk '{s+=$2} END {print s}' google_tts_usage.logで見られます
次に、再生まで面倒を見る google_tts_say.sh。ふだん呼ぶのはこちらです。
#!/bin/zsh
# Google Cloud Text-to-Speech で読み上げる
# 使い方: google_tts_say.sh <話者> <テキスト>
# 話者: leda(女声・既定) / charon(男声) / aoede / kore / または ja-JP-… のフルネーム
# 失敗したら Mac 内蔵の say -v Kyoko に自動で切り替える
SCRIPT_DIR="${0:A:h}"
VOICE_KEY="${1:-leda}"
shift
TEXT="$*"
[[ -z "$TEXT" ]] && { echo "ERROR: テキストが空" >&2; exit 1 }
MP3=$("$SCRIPT_DIR/google_tts_synth.sh" "$VOICE_KEY" "$TEXT")
if [[ -z "$MP3" || ! -s "$MP3" ]]; then
echo "WARN: Google TTS 失敗。Kyoko で読み上げます" >&2
exec say -v Kyoko "$TEXT"
fi
afplay "$MP3"
rm -f "$MP3"
合成に失敗したら黙って止まるのではなく、Kyokoが代打で出てきます。声の質は落ちても、返事が消えるよりずっといい。
ここまでを確かめる
~/Library/Scripts/google_tts_say.sh leda "聞こえていますか"
スピーカーから自然な声が出たら合格です。初回は gcloud の認証やAPI有効化のエラーがここで出るので、エラー文はこの1文の実行で拾えます。
続けて読むときは、再生の裏で次を合成する
会話のように複数の文を続けて読ませると、「読み終わる → 次の合成を待つ → 読む」の繰り返しになり、文と文の間に沈黙が挟まります。最初から2回通信すると分かっているなら、1本目を再生している間に2本目の合成を終わらせておけばいい。それをやるのが google_tts_queue.sh です。
#!/bin/zsh
# 話者の交代がある会話を、待ち時間を重ねて読み上げる。
# 「1つ目を読み上げている間に、2つ目の音声合成を裏で終わらせておく」仕組み。
#
# 使い方: google_tts_queue.sh 話者1 本文1 話者2 本文2 ...(話者と本文のペアを並べる)
SCRIPT_DIR="${0:A:h}"
SYNTH="$SCRIPT_DIR/google_tts_synth.sh"
typeset -a ARGS
ARGS=("$@")
PAIRS=$(( $#ARGS / 2 ))
(( PAIRS < 1 )) && { echo "ERROR: 話者と本文をペアで渡してください" >&2; exit 1 }
typeset -a FILES
i=1
# 1本目は再生前に合成が要るので、ここだけ待つ
FILES[1]=$("$SYNTH" "${ARGS[1]}" "${ARGS[2]}")
if [[ -z "${FILES[1]}" ]]; then
echo "WARN: Google TTS 失敗。Kyoko で読み上げます(この1本のみ)" >&2
say -v Kyoko "${ARGS[2]}"
FILES[1]=""
fi
while (( i <= PAIRS )); do
if [[ -n "${FILES[i]}" && "${FILES[i]}" != "__SPOKEN__" ]]; then
afplay "${FILES[i]}" &
PLAY_PID=$!
else
PLAY_PID=""
fi
next=$(( i + 1 ))
if (( next <= PAIRS )); then
# いま再生している間に、次の合成を終わらせておく
idx=$(( (next - 1) * 2 + 1 ))
FILES[next]=$("$SYNTH" "${ARGS[idx]}" "${ARGS[idx+1]}")
if [[ -z "${FILES[next]}" ]]; then
[[ -n "$PLAY_PID" ]] && wait $PLAY_PID
echo "WARN: Google TTS 失敗。Kyoko で読み上げます(この1本のみ)" >&2
say -v Kyoko "${ARGS[idx+1]}"
FILES[next]="__SPOKEN__"
PLAY_PID=""
fi
fi
[[ -n "$PLAY_PID" ]] && wait $PLAY_PID
[[ -n "${FILES[i]}" && "${FILES[i]}" != "__SPOKEN__" ]] && rm -f "${FILES[i]}"
i=$(( i + 1 ))
done
男声と女声を交互に使うと、対話の形でも聞けます。
google_tts_queue.sh charon "テストの結果を報告します。" leda "12件すべて通りました。"
Claudeに「声で返して」と教える
最後の配線です。Claude Code のメモリファイル CLAUDE.md(作業フォルダの直下に置く指示書。まだ無ければ新規作成でかまいません)に、返事の扱いを1行書いておきます。
- iPhoneからの音声入力(【iPhone音声】で始まる行)に返事をするときは、
要点を ~/Library/Scripts/google_tts_say.sh leda "……" でも読み上げること
これで、声で届いた指示には声で返ってくるようになります。画面の長い説明は画面に残したまま、要点だけ耳に届く、という分担です。
もうひとつ、離席中に「実行していいですか?」の確認で作業が止まらないよう、読み上げスクリプトなどよく使うコマンドは Claude Code の許可設定(settings.json の permissions)にあらかじめ足しておくと、ソファーから戻らずに済みます。
音声で運用して初めて出会った問題
キーボードで使っていた頃には存在しなかった問題が、2つ出ました。
Macの音声入力は、黙って117秒で帰る
ソファーではなくMacの前にいるとき、私はMac側の音声入力(Ctrlキー2回)も使います。ここに罠がありました。音声入力の部品(DictationIM)は、最後にしゃべり終わってから117秒きっかりで終了します。ストップウォッチで何度測っても117秒でした。誰が決めたのか知りませんが、きっかりです。
落ちていること自体は構わないのですが、その状態でCtrlキーを2回押すと、そのキーは部品の起動の合図に消費されて、マイクは出てきません。空振りです。もう一度押せば出ますが、話しかけるたびに「押す→出ない→もう一度押す」をやるのは、地味に心が削れます。
そこで、90秒ごとに生死を確認して、落ちていたら起こす係を置きました。落ちてから起こすまで最長90秒の隙間は残るので完全な防止ではありませんが、放っておけば落ちっぱなしだったものが勝手に復帰するので、空振りにはほとんど出会わなくなりました。
#!/bin/bash
# 音声入力の部品 DictationIM を、落ちたままにしない。
# 90秒ごとに LaunchAgent から呼ばれる。
LOG="$HOME/Library/Logs/dictation_keepalive.log"
if ! pgrep -x DictationIM >/dev/null 2>&1; then
launchctl kickstart "gui/$(id -u)/com.apple.DictationIM" 2>/dev/null
sleep 2
if pgrep -x DictationIM >/dev/null 2>&1; then
echo "$(date '+%Y-%m-%d %H:%M:%S') 起こした(PID $(pgrep -x DictationIM | head -1))" >> "$LOG"
else
echo "$(date '+%Y-%m-%d %H:%M:%S') ⚠ 起こせなかった" >> "$LOG"
fi
fi
# ログが太らないよう、1000行を超えたら古い分を捨てる
if [ -f "$LOG" ] && [ "$(wc -l < "$LOG")" -gt 1000 ]; then
tail -500 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
回すのはLaunchAgentですが、受け口とは形が変わります。このスクリプトは数秒で終わるので、KeepAlive は入れず、StartInterval で90秒ごとに呼びます(KeepAlive を残すと、終了のたびに即再起動がかかって90秒間隔になりません)。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.example.dictation-keepalive</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/あなたのユーザー名/Library/Scripts/dictation_keepalive.sh</string>
</array>
<key>StartInterval</key><integer>90</integer>
</dict>
</plist>
ひとつ失敗談を置いておくと、最初は open -a で起こそうとして、macOSに拒否されてクラッシュ報告まで出されました。この部品は launchctl kickstart で起こすのが正解です。
ポートは先客がいる
受け口のポートを最初8765にしたら、別のツールが既に使っていました。使う前に確認しておくと無駄がありません。
lsof -i :9100
# 何も出なければ空いている
まとめ——ゼロから組むチェックリスト
| # | やること | 対応する章 |
|---|---|---|
| 1 | スクリプト7本を ~/Library/Scripts/ に置き、chmod +x で実行権限を付ける |
全体の構成 |
| 2 | 受け口のLaunchAgentを登録する | 手順1 |
| 3 | iPhoneのショートカットを組む | 手順1 |
| 4 | SessionStart hook を登録する | 手順2 |
| 5 | Text-to-Speech API 有効化と gcloud auth login
|
手順3 |
| 6 | CLAUDE.md に読み上げの1行を書く | 手順3 |
| 7 | 音声入力を起こす係のLaunchAgentを登録する | 117秒問題 |
自分の環境に合わせて書き換える場所は、この5種類です。
| 書き換える場所 | 値 |
|---|---|
| コード中の「あなたのユーザー名」(plist 2本・settings.json・hook・tail) | 自分のmacOSのユーザー名 |
| iPhoneショートカットのURL | 自分のMacのローカルIP |
| ポート番号(server.py とショートカット) | 空いている番号 |
| google_tts_synth.sh の GCLOUD |
which gcloud で出た場所 |
| google_tts_synth.sh の PROJECT | 自分のGoogle CloudプロジェクトID |
見張りに渡す作業フォルダ名は、hook が basename "$PWD" で自動取得するので、手で書く場所はありません。
コード一式(スクリプト7本とLaunchAgentの設定例2本)はGitHubに置きました。
入力を口に、出力を耳に。やってみて分かったのは、変わるのは速さではなく、Macの置き場所と自分の姿勢が自由になることでした。読みかけの本を閉じずにテストが流せるのは、思っていたよりずっと快適です。
ひとつだけ注意を。ソファーでうっかりつぶやいた独り言も、Claudeは指示として実装してきます。そこだけは、覚悟がいります。