Voice Overlay Lifecycle for macOS: Wake-Word and Push-to-Talk Overlap

This page explains how the voice overlay behaves when both wake-word activation and push-to-talk are used together. It is intended for macOS app contributors who need to ensure predictable overlay behavior.

Read this when

  • Adjusting voice overlay behavior

Voice Overlay Lifecycle (macOS)

Intended for macOS app contributors. The objective is to ensure the voice overlay behaves predictably when both wake-word activation and push-to-talk are used together.

Behavior

  • When the overlay is already displayed due to a wake-word trigger and the user activates the hotkey, the push-to-talk session takes over the current text rather than clearing it. The overlay remains visible as long as the hotkey is pressed. Releasing the key sends the text if it contains non-whitespace characters; otherwise, the overlay is closed.
  • A wake-word activation continues to transmit automatically after a period of silence, while push-to-talk transmits immediately upon releasing the key.

Implementation

  • VoiceSessionCoordinator (apps/macos/Sources/OpenClaw/VoiceSessionCoordinator.swift) is the sole manager of the active voice session. It operates as a @MainActor @Observable singleton rather than an actor. Its public interface includes startSession, updatePartial, finalize, sendNow, dismiss, updateLevel, and snapshot. Every session is assigned a UUID token; any call that provides an outdated or mismatched token is ignored.
  • VoiceWakeOverlayController (VoiceWakeOverlayController+Session.swift) is responsible for displaying the overlay and relaying user interactions (requestSend, dismiss) back to the coordinator using the session token. It does not maintain any session state on its own.
  • When push-to-talk (VoicePushToTalk.begin()) is used, it takes the text already shown in the overlay and treats it as adoptedPrefix (by calling VoiceSessionCoordinator.shared.snapshot()). This means pressing the hotkey while the wake-word overlay is active preserves the existing text and adds any new speech to it. After the key is released, the system waits up to 1.5 seconds for a final transcription result, then falls back to whatever text is currently available.
  • On dismiss, the overlay invokes VoiceSessionCoordinator.overlayDidDismiss, which in turn calls VoiceWakeRuntime.refresh(state:). This ensures that closing the overlay manually, dismissing it because the text is empty, or dismissing it after sending all resume wake-word listening.
  • The unified send logic works as follows: if the trimmed text is empty, the overlay is dismissed. Otherwise, sendNow plays the send chime once, the text is forwarded via VoiceWakeForwarder, and then the overlay is dismissed.

Logging

The voice subsystem is ai.openclaw. Each component logs messages under its own category:

CategoryComponent
voicewake.coordinatorVoiceSessionCoordinator
voicewake.overlayVoiceWakeOverlayController/VoiceWakeOverlay
voicewake.pttPush-to-talk hotkey and capture
voicewake.runtimeWake-word runtime
voicewake.chimeChime playback
voicewake.syncGlobal settings sync
voicewake.forwardTranscript forwarding
voicewake.meterMic level monitor

Debugging checklist

  • Stream logs while reproducing a persistent overlay:

    sudo log stream --predicate 'subsystem == "ai.openclaw" AND category CONTAINS "voicewake"' --level info --style compact
    
  • Confirm that only one active session token exists at a time. The coordinator drops any stale callbacks.

  • Verify that releasing push-to-talk always calls end() with the current active token. If the text is empty, expect the overlay to be dismissed without playing the chime or sending.

503 words · updated Jul 27, 2026