DEV Community

Klakk 開発チーム
Klakk 開発チーム

Posted on

macOSメニューバーアプリの「無効化」でAudioEngineまで止めない理由

キー入力に短い効果音を付けるmacOSアプリでは、「無効化」をどこまで停止させるかが設計上の分岐になります。Event Tapだけを止めるのか、AVAudioEngineとPlayer Nodeも止めるのか、準備済みPCMバッファまで破棄するのか。

この記事では、有料MacアプリKlakkの現在の実装を例に、メニューバーから無効化したときに入力監視だけを停止し、Audio Engineを維持している理由と、その代わりに残る境界を整理します。

私はKlakkの開発者本人です。記事末尾に自社製品へのリンクがあります。

無効化で止めたいものを先に分ける

現在のKlakkでは、メニューバーの切り替えが次の3層に影響します。

  1. AppSettings.shared.isKlakkEnabled の永続状態
  2. KeyboardMonitor のEvent TapとRunLoop Source
  3. メニュー表示と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()
}
Enter fullscreen mode Exit fullscreen mode

ここでの「無効化」は、次のキー入力から新しい音をトリガーしないことを主目的にしています。

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 = []
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

メニューバーの一時無効化と、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を含む症状の診断、治療、改善を目的とした医療製品ではありません。

Links

Top comments (0)