自分用に、Macのメニューバーから使える音声入力アプリ「KoeBridge」を作りました。Swift製で、GeminiとAppleオンデバイスの認識方式を切り替えられます。
この記事では機能紹介よりも、音声認識の結果を普段の入力欄へ渡す部分の設計を紹介します。特に、非同期で結果が返る処理を、ユーザーの操作順に反映する方法を扱います。
対象読者は、Swiftで入力支援ツールを作っている方や、非同期処理の結果をUIへ反映する順番で悩んでいる方です。
「録音できる」と「安全に入力できる」は別の処理
音声入力の処理は、大きく次のように分けられます。
録音開始 → 音声認識 → 結果確定 → 入力先の照合 → 文字入力 → 反映の確認
認識が終わった時点で、入力先が録音開始時と同じとは限りません。別のアプリへ切り替えたかもしれませんし、同じウィンドウ内の別の入力欄をクリックしたかもしれません。
また、前の認識処理を待たずに次の録音を始められるようにすると、結果の返る順序も問題になります。
録音した順:A → B
認識完了順:B → A
完了した順に文字を入力すると、文章の順番が逆転します。そこでKoeBridgeでは、認識処理の完了と、結果の取り出しを分けています。
1. 完了した結果を、録音順に取り出す
OrderedResultBufferは、開始順を配列、完了した結果を辞書で保持します。
-
begin(id):録音の順番を登録する -
finish(id, result:):認識結果を保存する -
drain():先頭から連続して完了している分だけ取り出す
中心になるのは、次の部分です。
public mutating func drain() -> [(UUID, Value)] {
var items: [(UUID, Value)] = []
while let id = pending.first,
let value = resolved.removeValue(forKey: id) {
pending.removeFirst()
items.append((id, value))
}
return items
}
Bの結果が先に来ても、Aが未解決なら取り出しません。Aが解決した時点で、A、Bの順に返します。
注意点は、先頭の処理が失敗した場合です。成功した結果だけを登録する設計では、Aが失敗したままB以降を永遠に待たせてしまいます。失敗も一つの解決済み結果として扱い、後ろの処理へ進める必要があります。
このバッファ自体はスレッドセーフなコンテナではありません。KoeBridgeでは、これを所有するVoiceControllerを@MainActorに置いています。他のアプリへ転用する場合も、更新する実行コンテキストを揃える必要があります。
2. 重複したコールバックを二重入力にしない
finishでは、開始済みのIDで、まだ結果が登録されていない場合だけ受け付けます。
guard pending.contains(id), resolved[id] == nil else {
return false
}
resolved[id] = result
これにより、同じ完了通知が複数回来てもバッファから結果を重複して取り出しません。すでに取り出した古いIDや、登録していないIDも受け付けません。
ただし、ここで防げるのはバッファ内の重複処理です。OSへ送信した入力がアプリに反映されたかどうかは、別に確認する必要があります。
3. 「同じアプリ」だけでは入力先の判定が足りない
入力先の照合では、少なくとも次の情報を分けて考えます。
| 確認対象 | 確認する理由 |
|---|---|
| プロセス | 別のアプリへ移動していないか |
| ウィンドウ | 同じアプリの別画面へ移動していないか |
| 入力要素 | 同じ画面の別の入力欄へ移動していないか |
| 選択範囲 | 取得できる場合、カーソルや選択位置が変わっていないか |
KoeBridgeのInsertionFocusPolicyでは、プロセスとウィンドウだけでなく、同じ入力要素であることを確認します。選択範囲が取得できる場合は、その一致も判定に使います。
一方、選択範囲を提供しないエディターでは、同じ入力要素だと確認できても、その中でのカーソル移動は分かりません。「同じ要素を確認できた」と「カーソルまで確認できた」を同じ意味にしないことが重要です。
4. 入力を送信したことと、反映されたことを分ける
OSへ文字入力のイベントを送れても、入力先がそれを受け取ったとは限りません。
KoeBridgeでは、確認できる入力欄について、入力前の本文・選択範囲と、送信した文字列から期待する入力後の状態を組み立て、実際の状態と照合します。
このとき位置の計算にはUTF-16の長さを使います。String.countはユーザーが見る文字の単位に近い数え方なので、macOSの選択範囲に使う位置とそのまま置き換えられません。実装ではサロゲートペアの途中を選択範囲の境界として扱わない確認も入れています。
実装:InsertionReceiptPolicy.swift
ここで反映を確認できなかった場合、すぐ同じ文字を再送すると、遅れて反映された最初の入力と重なって二重入力になる可能性があります。そのため、送信済み・反映未確認の結果を盲目的に再送しない方針にしています。認識結果は履歴からコピーできるようにしています。
便利さだけを見ると、自動で何度も試したくなります。しかし、別の場所への誤入力や二重入力を避けるには、確認できない状態を独立して扱う必要があります。
5. 外部APIなしで確認できる部分を切り出す
順序管理のロジックは、マイクや認識APIを使わずにテストできます。リポジトリのテストでは、次のケースを扱っています。
- Bが先に完了しても、Aが解決するまで結果を取り出さない
- 同じIDの開始・完了通知を重複して受け付けない
- 先行処理の失敗を解決すると、後続の完了済み結果へ進める
- 保留件数の上限に達したら、新規登録を受け付けない
テスト:OrderedResultBufferTests.swift
このテストで確かめられるのは順序管理の振る舞いです。実際のマイク、音声認識サービス、入力先アプリとの組み合わせまで保証するものではありません。純粋なロジックのテストと実機確認は、分けて考えています。
まとめ
音声入力アプリでは、認識結果を得た後にも考えることがあります。
- 非同期処理の完了順と、ユーザーの操作順を分ける
- 失敗も解決済みとして扱い、後続を詰まらせない
- 同じアプリ内でも、入力欄や位置は変わる
- 送信成功と反映確認を区別し、不確かな入力を自動で再送しない
これらは音声入力に限らず、非同期で得た結果をユーザーの画面へ自動反映するツールでも使える考え方だと思います。
KoeBridgeのソースはMITライセンスで公開しています。実装全体や対応条件はGitHubにまとめています。
なお、Geminiモードは本人のAPIキーと別途API利用料が必要です。Appleオンデバイス認識は対応するMac・言語環境で利用できます。配布物のDeveloper ID署名・公証は未取得です。すべての入力先アプリへの対応や、認識精度・処理速度の優位性を保証するものではありません。