Voice Overlay Lifecycle When Wake-Word and Push-to-Talk Overlap on macOS
This page explains how the voice overlay behaves when wake-word and push-to-talk activations overlap. macOS app contributors will learn how the session coordinator manages state transitions.
Read this when
- Adjusting voice overlay behavior
Voice Overlay Lifecycle (macOS)
Audience: macOS app contributors. Goal: keep the voice overlay predictable when wake-word and push-to-talk overlap.
Behavior
- If the overlay is already visible from a wake-word activation and the user presses the hotkey, the hotkey session takes over the existing text rather than clearing it. The overlay remains visible while the hotkey is held. On release: send if there is trimmed text, otherwise dismiss.
- Wake-word alone still auto-sends on silence; push-to-talk sends immediately on release.
Implementation
VoiceSessionCoordinator(apps/macos/Sources/OpenClaw/VoiceSessionCoordinator.swift) is the sole owner of the active voice session. It is a@MainActor @Observablesingleton, not an actor. API:startSession,updatePartial,finalize,sendNow,dismiss,updateLevel,snapshot. Each session carries aUUIDtoken; calls with a stale or mismatched token are dropped.VoiceWakeOverlayController(VoiceWakeOverlayController+Session.swift) renders the overlay and forwards user actions (requestSend,dismiss) back through the coordinator using the session token. It never owns the session state itself.- Push-to-talk (
VoicePushToTalk.begin()) adopts any visible overlay text asadoptedPrefix(viaVoiceSessionCoordinator.shared.snapshot()) so pressing the hotkey while the wake overlay is up preserves the text and adds new speech. On release, it waits up to 1.5s for a final transcript before falling back to the current text. - On
dismiss, the overlay callsVoiceSessionCoordinator.overlayDidDismiss, which triggersVoiceWakeRuntime.refresh(state:)so manual X-dismiss, empty-text dismiss, and post-send dismiss all resume wake-word listening. - Unified send path: if trimmed text is empty, dismiss; otherwise
sendNowplays the send chime once, forwards viaVoiceWakeForwarder, then dismisses.
Logging
Voice subsystem is ai.openclaw; each component logs under its own category:
| Category | Component |
|---|---|
voicewake.coordinator | VoiceSessionCoordinator |
voicewake.overlay | VoiceWakeOverlayController/VoiceWakeOverlay |
voicewake.ptt | Push-to-talk hotkey and capture |
voicewake.runtime | Wake-word runtime |
voicewake.chime | Chime playback |
voicewake.sync | Global settings sync |
voicewake.forward | Transcript forwarding |
voicewake.meter | Mic level monitor |
Debugging checklist
-
Stream logs while reproducing a sticky overlay:
sudo log stream --predicate 'subsystem == "ai.openclaw" AND category CONTAINS "voicewake"' --level info --style compact
- Verify only one active session token; stale callbacks are dropped by the coordinator.
- Confirm push-to-talk release always calls `end()` with the active token; if text is empty, expect a dismiss without chime or send.
## Related
- [macOS app](/ai-agents/integrations/open-claw/docs/platforms/macos)
- [Voice wake (macOS)](/ai-agents/integrations/open-claw/docs/platforms/mac/voicewake)
- [Talk mode](/ai-agents/integrations/open-claw/docs/nodes/talk)