Files
openclaw/apps/macos/Sources/OpenClaw/TalkModeController.swift
Phillip Hampton 350fe63bbf feat(macos): Voice Wake option to trigger Talk Mode (#58490)
* feat(macos): add "Trigger Talk Mode" option to Voice Wake settings

When enabled, detecting a wake phrase activates Talk Mode (full voice
conversation: STT -> LLM -> TTS playback) instead of sending a text
message to the chat. Enables hands-free voice assistant UX.

Implementation:
- Constants.swift: new `openclaw.voiceWakeTriggersTalkMode` defaults key
- AppState.swift: new property with UserDefaults persistence + runtime
  refresh on change so the toggle takes effect immediately
- VoiceWakeSettings.swift: "Trigger Talk Mode" toggle under Voice Wake,
  disabled when Voice Wake is off
- VoiceWakeRuntime.swift: `beginCapture` checks `triggersTalkMode` and
  activates Talk Mode directly, skipping the capture/overlay/forward
  flow. Pauses the wake listener via `pauseForPushToTalk()` to prevent
  two audio pipelines competing on the mic.
- TalkModeController.swift: resumes voice wake listener when Talk Mode
  exits by calling `VoiceWakeRuntime.refresh`, mirroring PTT teardown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback

- TalkModeController: move VoiceWakeRuntime.refresh() after
  TalkModeRuntime.setEnabled(false) completes, preventing mic
  contention race where wake listener restarts before Talk Mode
  finishes tearing down its audio engine (P1)
- VoiceWakeRuntime: remove redundant haltRecognitionPipeline()
  before pauseForPushToTalk() which already calls it via stop() (P2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resume wake listener based on enabled state, not toggle value

Check swabbleEnabled instead of voiceWakeTriggersTalkMode when deciding
whether to resume the wake listener after Talk Mode exits. This ensures
the paused listener resumes even if the user toggled "Trigger Talk Mode"
off during an active session. (P2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: play trigger chime on Talk Mode activation + send chime before TTS

- VoiceWakeRuntime: play the configured trigger chime in the
  triggersTalkMode branch before pausing the wake listener. The early
  return was skipping the chime that plays in the normal capture flow.
- TalkModeRuntime: play the Voice Wake "send" chime before TTS playback
  starts, giving audible feedback that the AI is about to respond. Talk
  Mode never used this chime despite it being configurable in settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move send chime to transcript finalization (on send, not on reply)

The send chime now plays when the user's speech is finalized and about
to be sent to the AI, not when the TTS response starts. This matches
the semantics of "send sound" -- it confirms your input was captured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:09:36 -04:00

76 lines
2.4 KiB
Swift

import Observation
@MainActor
@Observable
final class TalkModeController {
static let shared = TalkModeController()
private let logger = Logger(subsystem: "ai.openclaw", category: "talk.controller")
private(set) var phase: TalkModePhase = .idle
private(set) var isPaused: Bool = false
func setEnabled(_ enabled: Bool) async {
self.logger.info("talk enabled=\(enabled)")
if enabled {
TalkOverlayController.shared.present()
} else {
TalkOverlayController.shared.dismiss()
}
await TalkModeRuntime.shared.setEnabled(enabled)
// Resume voice wake listener *after* TalkMode audio is fully torn down.
// Check swabbleEnabled (not voiceWakeTriggersTalkMode) so the paused wake listener
// resumes even if the user toggled "Trigger Talk Mode" off during the session.
if !enabled, AppStateStore.shared.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) }
}
}
func updatePhase(_ phase: TalkModePhase) {
self.phase = phase
TalkOverlayController.shared.updatePhase(phase)
let effectivePhase = self.isPaused ? "paused" : phase.rawValue
Task {
await GatewayConnection.shared.talkMode(
enabled: AppStateStore.shared.talkEnabled,
phase: effectivePhase)
}
}
func updateLevel(_ level: Double) {
TalkOverlayController.shared.updateLevel(level)
}
func setPaused(_ paused: Bool) {
guard self.isPaused != paused else { return }
self.logger.info("talk paused=\(paused)")
self.isPaused = paused
TalkOverlayController.shared.updatePaused(paused)
let effectivePhase = paused ? "paused" : self.phase.rawValue
Task {
await GatewayConnection.shared.talkMode(
enabled: AppStateStore.shared.talkEnabled,
phase: effectivePhase)
}
Task { await TalkModeRuntime.shared.setPaused(paused) }
}
func togglePaused() {
self.setPaused(!self.isPaused)
}
func stopSpeaking(reason: TalkStopReason = .userTap) {
Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) }
}
func exitTalkMode() {
Task { await AppStateStore.shared.setTalkEnabled(false) }
}
}
enum TalkStopReason {
case userTap
case speech
case manual
}