macOS の常駐アプリで、グローバルなキー入力に合わせて短い効果音を鳴らす実装を作りました。本稿では、CGEventTap と AVAudioEngine を組み合わせる際に重要だった設計点を整理します。
対象は「キーの内容を記録するアプリ」ではなく、入力イベントをきっかけにローカル音声を再生する用途です。
1. Event Tap は listen-only にする
キー入力を変更する必要がないなら、イベントタップは .listenOnly にします。
let mask = (1 << CGEventType.keyDown.rawValue)
| (1 << CGEventType.keyUp.rawValue)
| (1 << CGEventType.flagsChanged.rawValue)
let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .listenOnly,
eventsOfInterest: CGEventMask(mask),
callback: callback,
userInfo: context
)
コールバックではイベントを加工せず、そのまま返します。入力をブロックしない設計意図をコード上でも明確にできます。
macOS では Input Monitoring の許可が必要です。許可状態は IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) と CGPreflightListenEventAccess() を使って確認し、未許可時だけシステム設定への案内を出します。
2. keyDown だけでは長押しで音が重なる
keyDown は長押し時にリピートします。押下中のキーを Set で管理し、最初の keyDown だけで音を鳴らし、keyUp で削除する方法が扱いやすいです。
private var pressedKeys: Set<String> = []
func onKeyDown(_ key: String) {
guard !pressedKeys.contains(key) else { return }
pressedKeys.insert(key)
play(key)
}
func onKeyUp(_ key: String) {
pressedKeys.remove(key)
}
Shift、Command、Option などは flagsChanged で状態変化を追います。通常キーと修飾キーを同じ扱いにすると、押した瞬間と離した瞬間の判定が崩れやすい点に注意が必要でした。
3. 再生時にファイルを開かない
短い音でも、キー入力のたびに音声ファイルを開いて変換すると遅延と負荷が目立ちます。サウンドパックを選択した時点で AVAudioPCMBuffer に読み込み、出力フォーマットへの変換も済ませます。
実装では複数の AVAudioPlayerNode をあらかじめ AVAudioEngine に接続し、ラウンドロビンで使っています。複数キーの同時押しや速いタイピングでも、前の音を毎回止めずに重ねられます。
let nodes = (0..<10).map { _ in AVAudioPlayerNode() }
for node in nodes {
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: nil)
}
素材側の先頭に無音があると、コードが速くても体感遅延が残ります。そこで小さな閾値を超える最初のフレームを探し、数十フレームだけ余白を残して前方の無音を取り除きました。
4. 出力デバイス変更と Event Tap 無効化から復帰する
AirPods の接続や出力先変更では Audio Engine の再起動が必要になる場合があります。macOS では Core Audio の kAudioHardwarePropertyDefaultOutputDevice を監視し、変更時にエンジンを安全に再構築します。
また Event Tap はタイムアウトなどで無効になることがあります。一定間隔または一定イベント数ごとに CGEvent.tapIsEnabled を確認し、必要ならタップを作り直します。
権限が途中で外された場合は監視を停止し、再付与されたら再開する状態も持たせました。常駐アプリでは「最初に動いた」だけでなく、スリープ、権限変更、音声デバイス変更から戻れることが重要です。
プライバシー面で分けて考えたこと
Input Monitoring は強い権限です。音再生に必要なのはキーイベントであり、入力文字列の保存やアップロードではありません。実装と説明の両方で、次を分離しました。
- Event Tap は listen-only で、イベントを変更しない
- 入力した文字列や文章を保存しない
- キーストローク内容を送信しない
- 利用状況の分析イベントを使う場合は、入力内容とは別に明示する
おわりに
この実装は、私たちが開発している macOS メニューバーアプリ「Klakk」で使っています。製品紹介が本稿の主目的ではありませんが、実際の挙動を確認したい方向けにリンクを置きます。
- ブラウザデモ: https://tryklakk.com/ja/#demo
- Mac App Store: https://apps.apple.com/jp/app/id6754638652?pt=127956280&ct=jp-qiita-20260804&mt=12
- プライバシーポリシー: https://tryklakk.com/ja/privacy/