仕事ではBedrock経由でClaude Codeを使っています。そちらのバージョンではAIがあなたのマイクにアクセスできないので、ネイティブのClaudeデスクトップクライアントで入力が速くなる要因である音声入力がそもそもありません。私はタイピングよりもClaudeに話しかけるほうが好きです。数か月の間、その小さな摩擦が積み重なって、実際に直したいものになっていました。
解決策は明白でした。音を聞いて、文字起こしして、テキストをクリップボードに入れてくれるアプリです。使っている端末やテキスト入力欄に切り替えて貼り付ける。入力の途中に1ステップ挟むだけで、全部を打ち込む必要はありません。
やること
Mic to Clipboardは1画面、1ボタンです。マイクをタップして話す、もう一度タップする。文字起こし結果がクリップボードに届きます。あとは、貼り付けたい場所に貼るだけです。
これがアプリのすべてです。アカウントなし、同期なし、軽い/暗いモードのトグル以外の設定なし。動作は端末内だけで完結します。Appleの音声認識が文字起こしをローカルで行うので、何も電話から外へ出ません。
技術スタック
Swiftを書かずにiOSに出したかったので、Expo経由のReact Nativeを選びました。実際の作業は次の2つのパッケージが全部やってくれます:
-
expo-speech-recognitionはAppleのSFSpeechRecognizerAPIをラップ -
expo-clipboardは最終的な文字起こし結果を書き込み、システムのクリップボードに転送
Expoのマネージドワークフローのおかげで、開発中にXcodeを開かずに全部をビルドできました。App Storeへの提出用に設定が必要なときだけXcodeを触りました。
継続的な文字起こし
中核となるカスタムフックの面白い部分は、「継続的な音声認識が実際にどう動くか」です。Appleの認識器は、音声を処理しながら結果イベントを繰り返し発火します。それぞれの結果は、暫定(まだ処理中で、変わり得る)か最終(確定してコミット済み)です。しかし、自然な間がある長い文を話すと、最後に1つ大きな結果が返ってくるのではなく、最終結果が続けて複数回出てきます。
そこで、コミットされた最終結果をためていくrefを保持しています:
useSpeechRecognitionEvent("result", (event) => {
const text = event.results[0]?.transcript ?? "";
if (event.isFinal) {
if (text.trim()) {
accumulatedRef.current = accumulatedRef.current
? accumulatedRef.current + " " + text
: text;
setState((prev) => ({
...prev,
transcript: accumulatedRef.current,
interimTranscript: "",
}));
}
} else {
setState((prev) => ({
...prev,
interimTranscript: text,
}));
}
});
accumulatedRef はstateではなく単なるrefです。文の途中で更新されるたびに再レンダリングされるのを避けたいからです。stateの更新は最終結果のときだけ行います。セッションが終わるときに、ため込まれた文字列がクリップボードに書き込まれます。
画面に表示されるテキストは、その両方を組み合わせたものです。つまり「確定した分」+「進行中の暫定分」なので、話すのと同時に単語が出てくるのが見えます:
return {
...state,
toggle,
displayText: state.interimTranscript
? (accumulatedRef.current
? accumulatedRef.current + " " + state.interimTranscript
: state.interimTranscript)
: state.transcript,
};
端末内処理 vs. ネットワークへのフォールバック
新しいiPhoneは、完全に端末内での音声認識に対応しています。古い端末は、Appleのサーバーにフォールバックします。どちらか一方を選ぶだけでなく、このアプリは実行時に確認して適切な設定を使います:
const supportsOnDevice =
await ExpoSpeechRecognitionModule.supportsOnDeviceRecognition();
const config = supportsOnDevice ? SPEECH_CONFIG : SPEECH_CONFIG_NETWORK;
ExpoSpeechRecognitionModule.start(config);
2つの設定は requiresOnDeviceRecognition: true 以外は同一です。端末内処理は端末から何も出ないので優先されますが、古いハードウェアでそれを要求すると、何も起きていないように失敗するだけになってしまいます。フォールバックがそれを、ユーザーに見える違いなしに処理します。
no-speech エラーを黙らせる
マイクボタンをタップして、そのまま何も話さないと、認識器がコード no-speech を伴うエラーイベントを発火します。最初はそれを実際のエラーと同じ扱いにしていました。すると、誰かが考え直したり、うっかりボタンを押したりするたびに、UI がエラー状態に一瞬で切り替わってしまいました。
useSpeechRecognitionEvent("error", (event) => {
if (event.error === "no-speech") {
return;
}
// 実際のエラーを処理する
});
無言はエラーではありません。これを除外することで、ボタンは何事もなく単に待機状態に戻るだけになります。
難しい部分:App Store の書類作業
コードは週末で完成しました。App Store の審査を通すのは、想像していたよりも時間がかかり、もっと面倒でした。
Apple のプライバシー マニフェストシステムでは、使用しているシステム API と、その理由についての構造化された XML 宣言が必要です。expo-speech-recognition はマイクにアクセスし、特定の API を使うアプリは、Apple が解析できる形式で自分自身を説明する必要があります。Info.plist 内の権限文字列も、審査に通るのに十分に具体的である必要がありました。
さらに、暗号化に関する宣言もありました。HTTPS を使う(たとえ受動的にでも。すべてのアプリが該当します)どんなアプリでも、技術的には暗号化を使用していて、免除対象ではないとしてフラグを立てる必要があります。セキュリティ審査ではなく書類の手続きですが、チェックボックスを1つでも見落とすと提出が差し戻されます。
スクリーンショット要件が最も機械的な部分でした。iPhone 6.7" と 6.5" の各レイアウトに対して、特定のピクセル寸法が指定されていて、それをちょうどその解像度に合わせたシミュレータから撮る必要があります。最低 3〜5 枚の画面です。サイズが分かれば 20 分くらいの作業です。提出の途中で初めてそれらを調べるのは理想的ではありません。
現在の状況
App Store で公開中です。iPhone と iPad で動作します。Apple は「iPad 用に設計」互換レイヤーを通じて、Apple Silicon Mac 上でも自動的に利用可能にしてくれるため、こちら側で追加の作業はゼロです。
私は毎日使っています。デスクまでの歩きながら Claude の長文プロンプトを作成し、アプリを開いて読み上げ、その内容をターミナルに貼り付けます。会話の流れを止めないくらいの速さです。
もし違っていたらやること
Mac Catalyst の設定を省く。 「iPad 用に設計」を通じた自動の Mac 互換により、やりたかったことはすべてカバーできました。Catalyst のエンタイトルメント、サンドボックスの設定、Xcode のターゲットを整えるために時間を費やしたのですが、結局不要でした。
開発中にスクリーンショットを撮る。 私は最終ステップとして扱ってしまい、適切な解像度のシミュレータを設定するところで提出途中に詰まってしまいました。スクリーンショットはいつでも作れたはずです。
App Store の書類作業に丸1日を確保する。 コードは2日で完成しました。プライバシー マニフェスト、暗号化の宣言、権限文字列、スクリーンショット、プライバシーポリシーを正しく用意して揃えるのに、さらに丸1日かかりました。難しいわけではありませんが時間はかかり、これを省くことはできません。



