Claude Code や Codex に指示を打ち込むのがキーボードだとかったるい、と気づいたのが先週。
バイブコーディング(AI コーディングエージェントに自然言語で指示する形のコーディング)をやってると、指示文はどんどん長くなる。「この関数のエラーハンドリングを見直して、特に外部 API のタイムアウト時の挙動を確認して、あと型もちゃんと」みたいな指示を手で打つのは、単純にタイピング速度で律速されて思考が止まる。喋った方が 3 倍速い。
macOS 標準の音声入力(Globe キー 2 回)は試してみたが、バイブコーディング用の専門用語(関数名、ライブラリ名、コードで使う英単語混じり)が壊滅的で使えない。
選択肢は 3 つあった。
- Superwhisper を月 $8.49 で契約する(一番楽、バイブコーディング界隈で一番使われてる)
- MacWhisper を買い切り $25 で導入する
- 自作する
課金は潔くしたい派なのだが、Superwhisper は「こういう感じか」が想像つきすぎて面白味がなかった。自作してトラップに踏み抜かれながら構造を理解する方が、自分の道具として愛着も持てる。
というわけで週末自作した。結論から言うと 3 秒レイテンシで普通に実用に載るので、同じ路線を選びたい人のためにメモしておく。
完成品の動作

右 Option を長押しすると画面中央に赤い「🔴 REC」が出て「ピッ」と鳴る。喋って離すと「ポン」と鳴って「⏳ transcribing」に変わり、2-3 秒後にカーソル位置に整形済みテキストがペーストされる。
実装は以下の 5 つのピースで構成している。
- Hammerspoon(Lua スクリプト)— グローバルホットキー検知、録音制御、メニューバー表示、オーバーレイ表示
- sox — マイクからの WAV 録音
- mlx-whisper(
whisper-large-v3-turbo)— 音声 → テキスト転写。M1 Pro で realtime の 3-5 倍速 - Anthropic API(
claude-haiku-4-5)— 転写結果のフィラー除去・句読点整形 - pbcopy + osascript で Cmd+V 発火 — カーソル位置にペースト
全部で Lua 100 行、Python 90 行くらい。コードは地味だが、ここに至るまで複数回踏み抜かれた。
踏んだトラップを順に晒す

1. claude -p CLI は 10 秒かかって使い物にならない
最初は LLM 整形パートを Claude Code の claude -p サブコマンドで済ませようとした。認証がサブスクと共有されて楽だと思った。
結果、1 プロンプトで 10 秒超。しかも「’あ、えーと、テスト’ を整形して」と投げても「あ、えーと、テスト」とそのまま返ってきてフィラーが残る。フィラー除去を指示したのに Haiku がなぜか保守的にふるまう。
原因は claude -p が session 初期化・auto-memory 読み込み・CLAUDE.md 探索などの重い初期化を毎回やっているため。--bare フラグで全部切ると 1 秒以下になるが、--bare は ANTHROPIC_API_KEY 必須で結局 API 課金が必要。つまりサブスク認証で回す経路は実質死んでいる。
Anthropic API に $5 課金して SDK 直叩きに切り替えたら、整形 0.7 秒、精度も期待通りになった。Haiku 4.5 は $0.80 / 1M 入力トークンなので、$5 で数万回は楽に叩ける。バイブコーディングで 1 日 100 回使っても数ヶ月はもつ。
ここでひとつ重要な気づきがあって、Claude.ai / Claude Code のサブスク残高と、Anthropic API(console.anthropic.com)の残高は別物。Max プラン契約者でも、API を直接叩くなら別で課金が要る。billing も別画面。これは地味にわかりにくい。
2. Hammerspoon の子プロセスに環境変数が継承されない
hs.task で起動した Python プロセスは、login shell の env を継承しない。つまり ~/.zshrc で export ANTHROPIC_API_KEY=... しても読めない。
これは macOS の launchd 経由でアプリが起動される場合の共通の落とし穴。解決策は 2 つ:
hs.task:setEnvironment()で明示的に渡す- 別ファイル(
.env等)にキーを書いて、呼び出された Python 側で読む
後者を選んだ。~/voice-coding/.env を chmod 600 して、Python 側に軽量な .env loader を入れた。python-dotenv を使うほどでもない簡単さ。
def load_env_file(path: Path) -> None:
if not path.exists():
return
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
k, v = k.strip(), v.strip().strip('"').strip("'")
if k and v and k not in os.environ:
os.environ[k] = v
3. 右 Option だけを検知するのが意外と面倒
Hammerspoon の hs.eventtap で修飾キーを検知するとき、flagsChanged イベントの getFlags() が返すのは「Alt キーが押されているか」という抽象化された情報で、左 Option か右 Option かの区別がない。
しかも罠があって、左 Option を押したまま右 Option を押す → 右 Option を離す、というシナリオでは、右 Option の離上イベントが来ても flags.alt は true のまま(左がまだ押されてるから)。flags だけで状態追跡すると、離上を検知しそこねる。
正解は event:getRawEventData().CGEventData.flags を使って、NX_DEVICERALTKEYMASK = 0x40 ビットを直接見ること。これで右 Option の物理状態だけを取り出せる。
tap = hs.eventtap.new({hs.eventtap.event.types.flagsChanged}, function(event)
if event:getKeyCode() ~= 61 then return false end -- 61 = right option
local raw = event:getRawEventData().CGEventData.flags
local nowDown = (raw & 0x40) ~= 0
-- ...
end)
4. 連打したら WAV が消える race condition
最初の実装は WAV を固定パス(/tmp/voice-coding.wav)に書いて、「stop 後 0.25 秒待ってから transcribe 読み込み」という雑な構成だった。
これだと、短い間隔で連打されたとき、2 回目の録音開始が 1 回目の transcribe 前に WAV を削除してしまう。結果、1 回目の transcribe が「ファイルが無い」で失敗する。ログにはこう残る:
[11:34:28] sox started wav=/tmp/voice-coding-1776998068938.wav
[11:34:30] sox stopping, duration=1.75s
[transcribe.log] wav not found: /tmp/voice-coding.wav ← 前のセッションの
解決策は 2 つ組み合わせた:
- WAV パスをセッションごとにユニーク化(ミリ秒タイムスタンプ付き)
- 固定遅延待ちをやめて sox の終了コールバック経由で transcribe 起動
後者が肝で、hs.task.new のコールバックは sox プロセスが完全に終了してファイルハンドルを閉じた後に発火する。つまり WAV の flush が保証されている。固定 sleep じゃなくイベントドリブンにする、という古典的な良いパターンだ。
5. eventtap が macOS によって勝手に無効化される
動作テスト中、一度うまく動いた後、Right Option を押しても無反応になる現象に遭遇した。ログを見ると flagsChanged 自体が飛んできていない。
これは macOS が「重いコールバック」「反応しない eventtap」を検知して自動で tap を切る挙動。Hammerspoon の hs.eventtap は一度 disable されると勝手には復帰しない。
watchdog で 5 秒ごとに isEnabled() を見て、切れてたら :start() し直す、という自己修復を仕込んだ:
watchdog = hs.timer.new(5, function()
if tap and not tap:isEnabled() then
hlog("eventtap disabled, re-enabling")
tap:start()
end
end)
watchdog:start()
6. Whisper の silence hallucination
無音区間を食わせると Whisper が「Thank you.」「ご視聴ありがとうございました」などを返してくる。これは学習データ(YouTube 字幕が大量に入ってる)由来で有名な挙動。
短いテストで何度か「Thank you.」がカーソル位置にペーストされて苦笑いした。ブラックリスト方式で既知の hallucination パターンを引き算:
HALLUCINATIONS = {
"thank you.",
"thanks for watching.",
"ご視聴ありがとうございました",
"ご視聴ありがとうございました。",
"字幕 by",
"字幕作成",
}
def is_hallucination(text: str) -> bool:
t = text.strip().lower()
if not t or t in HALLUCINATIONS:
return True
return any(t.startswith(h) for h in HALLUCINATIONS)
より真面目には、録音の音量エネルギーを測って閾値以下なら transcribe 自体をスキップするのが筋だが、バイブコーディング用途ではブラックリストで十分だった。
7. LLM が整形プロンプトを読んで「質問」してくる
最初の cleanup プロンプトはこう書いていた。
以下は音声認識の出力です。バイブコーディング用の命令文として整形してください。
- フィラーを削除
- 句読点を整える
- ...
これで Haiku に投げると、ときどき以下のようなテキストが返ってくる。
入力テキストが韓国語のようですが、日本語として整形したい意図ですか?それとも韓国語のまま整形しますか?
そしてこの丁寧な質問文がカーソル位置にそのままペーストされる。エラー体験としてキレがある。
LLM を「助手」としてではなく「サイレント変換器」として使いたいのに、プロンプトが曖昧だと助手モードに入ってしまう。ルールを絶対ルールとして厳格化した:
あなたは音声認識出力を整形するツールです。以下の入力テキストを整形して、整形後のテキストのみを出力してください。
- 出力は整形後のテキスト1行のみ
- 質問・確認・前置き・説明・コードブロック・引用符で囲むなど一切禁止
- 意味を変えない、要約しない、翻訳しない、追加しない
- 入力が日本語なら日本語で、英語なら英語で返す(言語は変えない)
「禁止」「絶対」を明記するだけでかなり改善した。
動作の体感
日本語短文(2-3 秒の発話)で計測すると:
- Whisper 転写: 2.0-2.5 秒
- Haiku 整形: 0.5-1.0 秒
- 合計: 2.5-3.5 秒
3 秒というのは push-to-talk 用途としては十分実用域。タイピングで同じ文を打つより速い。
実例:
| 生 Whisper 出力 | Haiku 整形後 |
|---|---|
| 「ああああ 今日はいい天気いい天気かな」 | 「今日はいい天気かな。」 |
| 「あ、えーと、テストです」 | 「あ、テストです」 |
| 「今日はこれからVIVEコーディングをしていきます。いいですか?」 | 「今日はこれからVIVEコーディングをしていきます。いいですか?」 |
吃音と重複が綺麗に消えてる。「?」→「?」の全角変換もちゃんと効いてる。
一つ残った課題は、Whisper が「バイブコーディング」を「VIVEコーディング」と聞き取ること。initial_prompt で辞書を渡せば改善できるので、気が向いたら入れる。
フィードバックチャネルの設計

押した瞬間「録音されてるか分からない」問題は、開発初期から気になってた。menu bar の小さいアイコン変化だけじゃ視認性が弱い。
最終的に 3 チャネル構成に落ち着いた。
- 音: 開始
Tink.aiff、終了Pop.aiff(macOS 標準音) - 画面中央のオーバーレイ:
🔴 REC(赤) /⏳ transcribing(グレー) - メニューバー:
🎤/🔴/⏳
音は視線を外さずに状態を確認できるのが大きい。コーディング中はコードエディタから目を離したくないので、聴覚フィードバックは体験として一段上がる。オーバーレイは「ちゃんと録音されてる」の視覚的安心感のため。
local function playSound(path)
hs.task.new("/usr/bin/afplay", nil, {path}):start()
end
-- startRecording 内:
playSound(SOUND_START)
recAlertId = hs.alert.show("🔴 REC", REC_STYLE, 0)
hs.alert.show の第 4 引数に 0 を渡すと、hs.alert.closeSpecific(id) で明示的に閉じるまで表示されっぱなしになる。録音中だけ出して、停止と同時に閉じる。
コストと所要時間

- セットアップ所要時間: 1 時間(Hammerspoon + sox + mlx-whisper のインストール)
- コーディング: 2 時間(トラップ込み)
- API コスト: $5 で当面無限(Haiku 4.5 の 1 回整形 = 約 $0.0002)
Superwhisper は月 $8.49 ≒ 年 $100 なので、自作すれば実質クラウドコスト分だけで永続。なにより自分の道具として細部を調整できる。
まとめ

踏み抜かれたトラップは全部「知ってれば 3 分の調整で済む」類のもので、自作のコストは想像より低かった。特に厳しかったのは以下 2 つ:
claude -pの 10 秒レイテンシ問題(→ API 直叩きに切替)- LLM が助手モードに入って質問文を返す問題(→ プロンプトを絶対ルール化)
どちらも「LLM を道具として使う」という文脈で、他のプロジェクトでも踏みそうな普遍的な罠だった。
バイブコーディング用途では、口述 → 整形 → ペーストの 3 秒サイクルが回ることで、指示文の長さをあまり気にせず「思ったことを全部言える」状態になる。これはタイピングだと自然に切り詰められるのとは全然違う質の体験で、AI エージェントに渡す情報量が増えることでアウトプットの質も上がった実感がある。
同じ手触りを試したい人はぜひ自作してみてほしい。コードは全部公開するので、ブランチ切ってカスタマイズする感覚で使える(ホットキー変更、別 LLM 差し替え、ローカル Whisper のモデルサイズ変更は Lua と Python の数行で済む)。

コメント