iPhoneで録音した音声をテキスト変換して、Mac上のClaude Codeに転送する
Claude Code に出す指示は、込み入った話ほど長くなります。「この関数のここを直して、ただし前に決めたあの方針は崩さないで、それと似た箇所が他にもあった気がするから先に探して」——頭の中ではもう組み上がっているのに、それを打ち込む手が追いつかない。指が10本しかないのが悪い。
しゃべれば済むのに、と思いました。
「Mac の音声入力でいいのでは?」に先に答えます
Mac には音声入力が入っています。Control キーを2回押せばマイクが出る。私も最初はそれで足りると思っていました。
足りませんでした。最後にしゃべってから117秒で切れます。考えながらしゃべる人間には短すぎる。「えーっと、それでね」の "えーっと" が2分続いた時点でマイクは帰っています。押し直せば出てきますが、話の頭が毎回そこで折れる。
一方、iPhone の音声入力はよく粘ってくれます。認識も素直で、何より寝転がったまま使える。問題は、しゃべった文字が iPhone の中にあることだけです。
iCloud のファイル共有を経由して Mac へ渡す方法もありますが、届くまで数十秒かかります。同じ部屋にある2台の間で数十秒待つのは、なんだか納得がいきません。
そこで、家の Wi-Fi の中を直接通しました。1秒かかりません。
できあがるもの
iPhone に向かってしゃべる。Claude Code の画面に「【iPhone音声】さっきの関数のことなんだけど……」と出る。それだけです。
出たあとどうなるかを先に書いておきます。Claude はそれを「今こちらが言ったこと」として読み、そのまま動き出します。こちらが Enter を押す場面はありません。ソファでうっかりつぶやいた独り言が実装として返ってくるので、そこは覚悟がいります。
用意するものはこれだけです。
- iPhone:最初から入っている「ショートカット」アプリ
- Mac:
/usr/bin/python3
追加で買うものはありません。⚠ Mac の /usr/bin/python3 は、Command Line Tools を一度も入れていないと中身が空です。ターミナルで python3 -V と打ってバージョンが出れば大丈夫。「インストールしますか」と聞かれたら入れてください(無料・数分)。前提は2つ。iPhone と Mac が同じ Wi-Fi にいること、そしてMac が起きていること。スリープ中は届きません(届かなかったぶんは消えます)。
部品は3つだけ
| 部品 | 役割 | この記事のどこ |
|---|---|---|
| iPhone のショートカット | しゃべった文字を Mac へ送る | 手順3(画面の説明つき) |
| Mac の受け取りサーバ | 受け取って1行に出す(手順2からログファイルに溜まる) | 手順1(全文)・手順2(設定ファイルの全文) |
| Claude Code 側の見張り | ログを読んで会話に流し込む | 手順4(全文)・落とし穴の章(全文) |
つなぐと、こういう流れになります。
⭐ 手順1から4は、この図を左上から順にたどるだけです。 いま自分がどこを作っているのか分からなくなったら、ここへ戻ってきてください。
コードは全部で120行ほどです。全文を載せます。
手順1から4まで、それぞれの終わりに「ここまで動いたことを目で確かめる方法」を書きました。まとめて作ってから動かすと、うまくいかないときにどこが悪いのか分からなくなるので、1つずつ確かめながら進んでください。
手順1/Mac に受け取りサーバを置く
Python の標準ライブラリだけで書けます。~/bin/voice_server.py として保存してください(~/bin が無ければ作ります)。
#!/usr/bin/env python3
"""iPhone のショートカットから送られた音声入力テキストを受け取り、標準出力に出す。"""
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)
説明が要りそうなところを3つ。
0.0.0.0 で待ち受けているのは、他の機械からの接続を受けるためです。ここが 127.0.0.1 だと Mac 自身からしか届きません。
flush=True を外さないでください。これが無いと出力が Python の中に溜まり、iPhone からは届いているのに Mac 側には何も出てこない、という一番たちの悪い症状になります。
log_message を空にしているのは、標準の接続ログを黙らせるためです。この行を消すと、あとで Claude 側に接続ログまで流れ込んで通知だらけになります。
ポート番号の決め方
9100 という数字に意味はありません。私の Mac では 8765 と 8766 が別のサーバで埋まっていたので、空いていた番号にしただけです。
使えるかどうかはこう調べます。
lsof -i :9100
何も出てこなければ空きです。行が出てきたら、そのポートは誰かが使っています。別の番号にしてください。
⚠ 番号を変えるときは2か所を直します。片方だけ直すと、何も言わずに届かなくなります。
| 直す場所 | 何を |
|---|---|
voice_server.py |
PORT = 9100 の数字 |
| iPhone のショートカット | URL の末尾の数字(手順3) |
手順2で作る設定ファイルには番号が出てこないので、そちらは触らなくて構いません。⚠ この記事に出てくる確認用のコマンド(lsof -i :9100・curl ... :9100)にも番号が入っているので、コピーして使うときはそこも直してください。
ここまでを確かめる
ターミナルを2つ開きます。1つ目でサーバを起動。
python3 ~/bin/voice_server.py
2つ目から、自分自身に向けて送ります。
curl -X POST -d "テストです" http://127.0.0.1:9100
1つ目のターミナルに 【iPhone音声】テストです と出れば成功です。この段階では、文字は画面に出るだけでファイルには残りません。ファイルに残るのは次の手順からです。
出なかったら、curl 側に OK が返っているかを見てください。OK すら返らないならサーバが起動していません。
⚠ 確かめたら、1つ目のターミナルで Control+C を押してサーバを止めてください。 ここで止め忘れると、次の手順で常駐させたサーバがポート 9100 を取れず、起動と失敗をひたすらくり返します。しかもそのとき curl は止め忘れたほうのサーバに届いてしまうので、症状が「なぜかログファイルにだけ出ない」という分かりにくい形になります。
手順2/Mac が起きている間ずっと動かす
ターミナルを終了するとサーバも止まります。ログインしたら勝手に立ち上がるようにします。macOS の launchd に頼みます。
~/Library/LaunchAgents/com.example.voice.plist として保存してください(com.example の部分は自分の好きな名前で構いません。ただしファイル名と中の Label は揃えます)。
<?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.voice</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>-u</string>
<string>/Users/あなたのユーザ名/bin/voice_server.py</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/あなたのユーザ名/Library/Logs/voice.log</string>
<key>StandardErrorPath</key>
<string>/Users/あなたのユーザ名/Library/Logs/voice.error.log</string>
</dict>
</plist>
⚠ パスに ~ は使えません。フルパスで書いてください。
各行の役割です。
| 項目 | 意味 |
|---|---|
RunAtLoad |
ログインしたら起動する |
KeepAlive |
落ちても起こし直す |
-u |
Python の出力を溜めずにすぐ出す(flush=True と同じ狙いの念押し) |
StandardOutPath |
標準出力の行き先をファイルにする |
⚠ /usr/bin/python3 の部分は、自分の Mac で本当にそこにあるか確かめてから書いてください。
which python3
/usr/bin/python3 以外の場所が出た人(Homebrew などで入れた場合)は、出てきたパスをそのまま plist に書きます。ここが違っていると、症状は「ログに行が出ない」だけで、原因は voice.error.log を見ないと分かりません。
⭐ 最後の1行が、この記事で一番効いています。手順1では画面に出ていた「【iPhone音声】〜」が、ここからファイルに溜まるようになります。そしてそのファイルが、手順4で Claude が読む入口になります。
読み込みと停止はこうします。
# 読み込む(起動する)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.voice.plist
# 止める
launchctl bootout gui/$(id -u)/com.example.voice
ここまでを確かめる
ログを見ながら、さっきと同じ curl を送ります。
# 1つ目のターミナル
tail -n 0 -f ~/Library/Logs/voice.log
# 2つ目のターミナル
curl -X POST -d "常駐のテスト" http://127.0.0.1:9100
tail の側に行が増えれば成功です。増えないときは ~/Library/Logs/voice.error.log を見てください。Address already in use と出ていたら、手順1のサーバを止め忘れています。パスの書き間違いもここに出ます。
⚠ tail に付けた -n 0 は「今より後に増えた行だけ見せろ」という指定です。付けないと過去10行が先に流れます。ここでは邪魔なだけですが、手順4では付け忘れると実害が出ます(そこで改めて書きます)。
このログファイルは放っておくと伸び続けます。とはいえ1行が数十文字なので、しばらくは気にしなくて大丈夫です。気になったら消してください。消しても KeepAlive が入っているので、次に届いた行から作り直されます。
手順3/iPhone のショートカットを作る
まず自分の Mac のアドレスを調べる
ipconfig getifaddr $(route -n get default | awk '/interface:/{print $2}')
192.168.11.6 のような数字が出ます。これはあなたの Mac の番号なので、記事に出てくる数字をそのまま使わないでください。
⚠ ipconfig getifaddr en0 と書いている記事をよく見かけますが、en0 が Wi-Fi とは限りません。私の Mac では en0 が有線、en1 が Wi-Fi で、en0 を見にいくと何も返ってきませんでした。上のコマンドは「今つながっている口」を自動で選ぶので、機種の違いを気にせずに済みます。
自分の Mac がどちらなのかを見たいときはこれで出ます。
networksetup -listallhardwareports
ショートカットを組む
「ショートカット」アプリで新規作成します。実際の画面はこうなります。
1つ目:テキストを音声入力
検索欄に「音声入力」と入れると出てきます。これを置くと、実行したときにマイクが開きます。
2つ目:クリップボードにコピー(無くても動きます)
しゃべった文字を iPhone のクリップボードにも残しておくアクションです。送信そのものには要りません。私が入れているのは、うまく届かなかったときに、しゃべり直さずに他のところへ貼れるようにするためです。要らない人は飛ばしてください。
3つ目:URLの内容を取得
検索欄に「URL」と入れて選びます。置いただけでは URL の欄しか見えないので、アクションの右下にある下向きの矢印マークをタップして開きます。開くと、方法・ヘッダ・本文を要求 の欄が出てきます。
設定はこうです。
| 欄 | 入れるもの |
|---|---|
| URL |
http://192.168.11.6:9100(自分の番号に置き換え) |
| 方法 | POST |
| 本文を要求 | ファイル |
| ファイル | 変数「音声入力されたテキスト」 |
⚠⚠ 「本文を要求」は「テキスト」ではなく「ファイル」を選びます。 ここが一番間違えやすいところです。しゃべった文字を送るのだからテキストだろう、と思うのが自然ですが、それだと Mac 側に狙った形で届きません。「ファイル」を選ぶと、変数の中身がそのまま本文として送られます。
最後の行がもう1つのつまずきどころです。「ファイル」の欄をタップすると、キーボードの上に変数の候補が並びます。そこから「音声入力されたテキスト」を選びます。手で文字を打つのではなく、候補から選ぶのが正解です。青く囲まれた形になれば入っています。
作ったら、名前を付けてホーム画面に置くか、背面タップに割り当てます。私は背面ダブルタップにしました。iPhone の裏を2回タップするとマイクが開きます。
許可を2つ通す
初回に許可を聞かれます。どちらも通さないと届きません。しかも断ったあとは黙って失敗するだけなので、気づきにくいところです。
iPhone 側——「ローカルネットワーク上のデバイスへのアクセスを許可しますか」。誤って拒否した場合は、設定 → ショートカット → ローカルネットワーク で戻せます。
Mac 側——ファイアウォールを入れていると「"python3" は着信ネットワーク接続を受け付けますか」というダイアログが出ます。⚠ 常駐させたあとは、このダイアログが出ないまま拒否になっていることがあります(画面に出ても気づかずに閉じてしまうため)。設定 → ネットワーク → ファイアウォール → オプション を開いて、python3 が「着信接続を許可」になっているか見てください。ファイアウォールを切っている人は、この確認は不要です。
ここまでを確かめる
Mac で tail -n 0 -f ~/Library/Logs/voice.log を開いたまま、iPhone のショートカットを実行してしゃべります。
ログに行が増えれば成功です。ここまでで、iPhone の声が Mac のファイルに届くようになりました。あとは Claude に読ませるだけです。
手順4/Claude Code に拾わせる
Monitor という道具
Claude Code には Monitor という道具が入っています。「見張りのコマンド」を1つ渡すと、そのコマンドが標準出力に出した1行が、そのまま会話に1つの通知として届くという仕組みです。
大事なところを4つ。
- Claude Code に入っている道具です。入れる作業はありません
- 起動するのは Claude 自身です。こちらがコマンドを打つ場面はありません
- 届いた通知は、会話の中に流れてきます
-
見張りには期限があります。既定では5分ほどで切れるので、ずっと続けてほしいときは
persistentという指定を付けます。付けるとセッションが終わるまで生き続けます
つまり、さっきのログファイルを見張らせればいいわけです。ファイルの中身を標準出力に流し続けるコマンドは、もう使っています。tail -n 0 -f です。
まず手で1回やってみる
自動にする前に、頼んで動かしてみてください。Claude Code にこう言います。
tail -n 0 -f ~/Library/Logs/voice.log を Monitor で persistent 付きで見張って
⚠⚠ ここで -n 0 を落とさないでください。 素の tail -f は過去10行を先に出します。手順2と手順3で「テストです」「常駐のテスト」などを既に流しているので、見張りを立てた瞬間にそれが通知として届きます。そして Claude は届いた文字をこちらの指示として読んで動き出します。テスト用の文字列ならまだ笑い話ですが、前に本気で頼んだ内容が1つ前の会話から蘇ってくると、それなりに困ったことになります。
立ったかどうかは、Claude が「見張りを始めた」と応答して、そのまま次の入力待ちに戻るかで分かります。 見張りは裏で動き続けるので、会話は普通に続けられます。
立ったら iPhone でしゃべります。会話に「【iPhone音声】〜」が出てくれば成功です。ここまで来れば仕組みは完成していて、残りは「毎回これを頼まなくていいようにする」だけになります。
手で立てた見張りは、そのセッションを閉じれば消えます。次の手順に進む前に Claude Code を一度閉じてください。閉じずに進むと、手で立てた見張りと自動で立った見張りが2本になり、しゃべった1回が同じ会話に2回出ます。
毎回自動で立てる
Claude Code には hook という仕掛けがあります。決まった場面で自分のスクリプトが呼ばれる、というものです。今回使うのは SessionStart——セッションが始まった瞬間に呼ばれるhook です。
hook の実体はシェルスクリプトです。~/bin/voice_hook.sh として保存してください。
#!/bin/bash
# セッション開始時に呼ばれ、Claude へ「見張りを立てろ」と伝える。
FOLDER=$(basename "$PWD")
cat <<JSON
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "このターンの最初に Monitor ツールで次を仕掛けること(persistent: true, timeout_ms: 3600000):$HOME/bin/voice_tail.sh ${FOLDER} 。説明は1行だけにして、通常の作業を続けること。"
}
}
JSON
ここが hook の肝です。標準出力に上の形の JSON を出すと、additionalContext の中身がそのままセッションの冒頭に差し込まれます。Claude から見ると、こちらが最初にそう頼んだのと同じことになります。指示文なので、日本語で普通に書けます。
呼び出しの登録は ~/.claude/settings.json です。このファイルは既にあるはずなので、丸ごと差し替えず hooks の中に足してください。
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "sh /Users/あなたのユーザ名/bin/voice_hook.sh"
}
]
}
]
}
}
貼り方は自分のファイルの状態で変わります。
今の settings.json
|
やること |
|---|---|
hooks が無い |
上の "hooks": { ... } を丸ごと足す(前の項目の末尾にカンマを付ける) |
hooks はあるが SessionStart が無い |
"SessionStart": [ ... ] の部分だけを hooks の中に足す |
SessionStart も既にある |
その配列の中に { "hooks": [ { "type": "command", "command": "sh ..." } ] } を1つ足す |
⚠ 足したら、JSON として壊れていないかを確かめてください。カンマの付け忘れが一番多い間違いです。
python3 -m json.tool ~/.claude/settings.json > /dev/null && echo "JSON は正しい"
⚠ voice_tail.sh はまだ作っていません。次の章で作ります。 先に Claude Code を開き直すと、見張りは立とうとしてファイルが無いと言われます。次の章まで進んでから開き直してください。
ここまでを確かめる(次の章のあとで)
voice_tail.sh を作ってから Claude Code を開き直します。最初のターンで見張りが立てば成功です。そのまま iPhone でしゃべって、会話に出てくることを確認してください。
うまくいかないときは、hook を手で動かしてみると早いです。
sh ~/bin/voice_hook.sh
JSON がそのまま表示されれば hook 自体は正しいです。エラーが出る、または何も出ないなら、原因はスクリプトの側にあります。ここが通っているのに見張りが立たないなら、settings.json のパスの書き間違いを疑ってください。
落とし穴/Claude Code を2つ開いていると、両方が拾う
私は普段、フォルダごとに Claude Code を複数開いています。この状態でしゃべると、1回の声が全部のセッションに届いて、全員が同時に動き出しました。片方に頼んだつもりの修正が、関係ないフォルダでも始まる。
⚠ この章は、VS Code で Claude Code を開いている人向けです。 ターミナルで動かしている場合はウィンドウ名が取れないので、この振り分けは使えません。
ターミナル派の人の最終形はこうなります。voice_tail.sh は作らず、voice_hook.sh の中の
$HOME/bin/voice_tail.sh ${FOLDER}
の部分を
tail -n 0 -f $HOME/Library/Logs/voice.log
に書き換えてください。そのうえで、Claude Code を同時に1つだけ開いて使うという運用にします。それで困りません。この章はここまで読まずに飛ばして構いません。
どこで止めるか
ログは1本しかなく、それを全セッションが読んでいます。ログの側では止められません。書いた時点では、誰宛てなのか決まっていないからです。
止めるのは読む側です。Monitor に渡すコマンドを、素の tail から振り分けスクリプトに差し替えます。前の章の voice_hook.sh が既に $HOME/bin/voice_tail.sh ${FOLDER} を渡す形になっているので、そのファイルを作れば差し替えは完了です。hook を直す必要はありません。
~/bin/voice_tail.sh として保存してください。
#!/bin/bash
# 手前にある VS Code のウィンドウ名が、担当フォルダ名と一致するときだけ行を出す。
# 使い方: voice_tail.sh <フォルダ名>
FOLDER="$1"
LOG="$HOME/Library/Logs/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
chmod +x ~/bin/voice_tail.sh
⚠ こちらは実行権限が要ります。Monitor はこのファイルのパスをそのまま実行するためです(voice_hook.sh のほうは sh を付けて呼んでいるので不要でした)。
判定しているのは「通知を出す直前」です。ログに行が来るたびに、そのとき最前面にあるウィンドウの名前を取り、自分の担当フォルダ名と一致したときだけ echo します。一致しなかったセッションは黙るだけで、ログの行は消えません。他のセッションもちゃんと同じ行を見ています。
結果として、しゃべる直前にクリックしておいたウィンドウが受け取り手になります。狙ったセッションに話しかけたければ、iPhone を持つ前にそのウィンドウを1回クリックする。それだけです。
⚠ osascript が動くには「アクセシビリティ」の許可が要ります。設定 → プライバシーとセキュリティ → アクセシビリティ を開いてください。
追加するのは「Claude Code を動かしているアプリ」です。 この章は VS Code 前提なので、Visual Studio Code を追加します(VS Code の中のターミナルから動いているため、Terminal.app ではありません)。
許可が無いと front_window は常に空を返し、どのセッションにも届かなくなります。しかもエラーは出ません。静かに何も起きなくなるので、この章まで動いていたものが急に黙ったら、まずここを疑ってください。手で確かめるならこれを実行します。
osascript -e 'tell application "System Events" to name of first process whose frontmost is true'
アプリ名が返れば許可は通っています。 空や権限のエラーが返るなら、追加できていません。
気をつけること
同じ Wi-Fi にいる人は、誰でも送り込めます
この作りにはパスワードの確認がありません。同じネットワークにいて、Mac の番号とポート番号を知っている人は、誰でも文字を送り込めます。そして送り込まれた文字は、Claude への指示として読まれます。
家の Wi-Fi で使ってください。会社・カフェ・宿のネットワークでは動かさないでください。持ち出すなら、決めておいたパスワードを一緒に送らせて、一致しないときは受け取らない、という作りに変えてから使います。ショートカット側でヘッダに1行足し、voice_server.py の do_POST の先頭でその値を見て、違えば何もせずに返す——という直し方になります。
Mac の番号は変わることがあります
ルーターが番号を配り直すと変わります。届かなくなったら、まず ipconfig getifaddr en0 を見てください。頻繁に変わるなら、ルーター側で固定してしまうのが早いです。
音声入力なので、誤変換が乗ります
「ファイル」が「会津」になり、「事業」が「授業」になります。私は誤変換のパターンを表にしておき、それを読み替えの手がかりとして使っています。固有名詞ほどよく化けるので、よく使う言葉から順に足していくといいです。
うまくいかないときの見方
| 症状 | 疑うところ | 確かめ方 |
|---|---|---|
| ログに行が出ない | サーバ、またはショートカット |
curl で送って出るか見る |
curl では出るが iPhone からは出ない |
番号・ポート・Wi-Fi・許可 | 同じ Wi-Fi にいるか/ローカルネットワークの許可 |
| ログには出るが Claude に出ない | 見張り、または振り分け | 見張りが立っているか/担当フォルダ名が合っているか |
| どのセッションにも出ない | アクセシビリティの許可 |
osascript を手で動かしてウィンドウ名が返るか |
まとめ
部品は3つ、コードは120行ほど。買うものはありません。
- iPhone のショートカットが、しゃべった文字を Mac へ送る
- Mac のサーバが受け取ってログに書く
- Claude Code の Monitor がログを見張って、会話に流し込む
しゃべった内容がそのまま実装として返ってくるので、独り言には気をつけてください。
なお、逆向き——Mac で動かしている自作アプリの画面を iPhone から開く話は、別記事に書きました。同じ「家の Wi-Fi の中で2台をつなぐ」でも、設定するものがまるで違います。
