キー入力に短い効果音を付けるmacOSアプリでは、「無効化」をどこまで停止させるかが設計上の分岐になります。Event Tapだけを止めるのか、AVAudioEngineとPlayer Nodeも止めるのか、準備済みPCMバッファまで破棄するのか。
この記事では、有料MacアプリKlakkの現在の実装を例に、メニューバーから無効化したときに入力監視だけを停止し、Audio Engineを維持している理由と、その代わりに残る境界を整理します。
私はKlakkの開発者本人です。記事末尾に自社製品へのリンクがあります。
無効化で止めたいものを先に分ける
現在のKlakkでは、メニューバーの切り替えが次の3層に影響します。
-
AppSettings.shared.isKlakkEnabledの永続状態 -
KeyboardMonitorのEvent TapとRunLoop Source - メニュー表示とAnalyticsイベント
一方、AudioEngine.shared.stop() はこの切り替えから呼びません。
@objc private func toggleKlakk(_ sender: NSMenuItem?) {
AppSettings.shared.isKlakkEnabled.toggle()
AnalyticsService.shared.trackKlakkToggle(
enabled: AppSettings.shared.isKlakkEnabled
)
if AppSettings.shared.isKlakkEnabled {
KeyboardMonitor.shared.startMonitoring()
} else {
KeyboardMonitor.shared.stopMonitoring()
}
statusItem?.menu = createMenu()
}
ここでの「無効化」は、次のキー入力から新しい音をトリガーしないことを主目的にしています。
Event Tapを無効化するだけでは足りない
KeyboardMonitor.stopMonitoring() は、CGEvent.tapEnable(..., false)だけでは終わりません。
func stopMonitoring() {
guard isMonitoring else { return }
if let eventTap {
CGEvent.tapEnable(tap: eventTap, enable: false)
CFMachPortInvalidate(eventTap)
}
if let runLoopSource {
CFRunLoopRemoveSource(
CFRunLoopGetCurrent(),
runLoopSource,
.commonModes
)
}
self.eventTap = nil
self.runLoopSource = nil
isMonitoring = false
pressedKeys.removeAll()
previousModifierFlags = []
}
Event Tapを無効化し、Mach Portをinvalidateし、RunLoop Sourceを外します。さらに、押下中キーと修飾キーの前回状態を破棄します。
最後の二つがないと、再有効化後に「前回のセッションで押されたまま」と判断し、最初のkeyDownを重複扱いする可能性があります。監視セッションを跨いで入力状態を持ち越さないことが重要です。
Audio Engineを維持する理由
KlakkのAudio Engineは、起動後に音声ファイルをPCMバッファへ準備し、固定のAVAudioPlayerNodeプールを使います。無効化のたびにEngine全体を止めて再構築すると、次の有効化で準備コストと失敗点が増えます。
- 出力デバイスの再確認
- Audio Formatの再構築
- Player Nodeの再接続
- Engine startの失敗処理
- 最初の一音までの遅延
入力監視を止めれば、新しいキーイベントはAudio Engineへ届きません。そのため、短い一時停止ではEngineと準備済みバッファを維持し、次の再開を軽くしています。
アプリ終了時やAudio Engine自体のクリーンアップでは、別のstop()がProperty Listener、Player Node、Engineを停止します。
func stop() {
if let listener = defaultDevicePropertyListener {
AudioObjectRemovePropertyListener(
AudioObjectID(kAudioObjectSystemObject),
&address,
listener,
nil
)
defaultDevicePropertyListener = nil
}
for playerNode in playerNodePool {
playerNode.stop()
}
audioEngine.stop()
}
メニューバーの一時無効化と、Audio System全体の終了を同じ操作にしていません。
「即時停止」の境界
この設計で確実に止まるのは、新しいキーイベントによる追加再生です。
一方、無効化した瞬間にすでにPlayer Nodeへscheduleされている短い音は、現在の実装ではその末尾まで再生される可能性があります。KeyboardMonitor.stopMonitoring()はPlayer Nodeへstop()を送らないためです。
したがって、UI文言やテストで「無効化した瞬間に全サンプルがゼロフレームで切れる」とは約束できません。必要要件が厳密なミュートなら、次の選択肢を別に設計する必要があります。
- 全Player Nodeを停止する
- Mixer Volumeを即座に0へ下げる
- 短いfade-outを入れる
- 再有効化時にNodeとschedule状態を再同期する
ただし、クリックノイズ、再開遅延、同時実行との競合が増えるため、単純なaudioEngine.stop()だけでは終わりません。
権限の撤回は別のライフサイクル
ユーザーがInput Monitoring権限を取り消した場合は、単なる一時無効化とは違います。現在の実装は定期的に権限を確認し、撤回を検出したら監視を止めます。完全停止用のstopMonitoringCompletely()では権限確認Timerも停止し、wasMonitoringもリセットします。
UI上の「無効」、権限の撤回、アプリ終了を一つのBooleanへ押し込まないほうが、再開条件を説明しやすくなります。
まとめ
短いメニューバーアプリでは、無効化を「すべてのサブシステムを破棄する操作」と考えないほうが扱いやすい場合があります。
- 入力監視を止めて、新しいトリガーを遮断する
- Event TapとRunLoop Sourceを確実に破棄する
- 押下中キーとModifier状態をリセットする
- Audio EngineとPCM準備状態は短い再開のため維持する
- 再生中サンプルの末尾は残る可能性を仕様境界として扱う
- 権限撤回とアプリ終了は別の停止経路にする
KlakkはMacから追加の打鍵音を再生するアプリです。実物のキーボードのスイッチ、重さ、打鍵感、物理音は変えません。ASMRや集中しやすさには個人差があり、ADHDを含む症状の診断、治療、改善を目的とした医療製品ではありません。
Top comments (0)