diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index e9c0e4a299a1..9653b6214590 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -30059,7 +30059,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 399, + "line": 403, "path": "apps/macos/Sources/OpenClaw/AppState.swift", "source": "These settings changed outside the app while you were editing: \\(fieldList). Choose which version to keep.", "surface": "apple", @@ -30067,7 +30067,7 @@ }, { "kind": "conditional-branch", - "line": 1017, + "line": 1022, "path": "apps/macos/Sources/OpenClaw/AppState.swift", "source": "\\(user)@\\(host)", "surface": "apple", @@ -30075,7 +30075,7 @@ }, { "kind": "conditional-branch", - "line": 1017, + "line": 1022, "path": "apps/macos/Sources/OpenClaw/AppState.swift", "source": "\\(user)@\\(host):\\(port)", "surface": "apple", @@ -39139,7 +39139,7 @@ }, { "kind": "ui-named-argument", - "line": 187, + "line": 189, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Push-to-talk paused", "surface": "apple", @@ -39147,7 +39147,7 @@ }, { "kind": "ui-named-argument", - "line": 188, + "line": 190, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Push-to-Talk resumes when Talk Mode is turned off.", "surface": "apple", @@ -39155,7 +39155,7 @@ }, { "kind": "ui-named-argument", - "line": 196, + "line": 198, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Play phase-transition sounds", "surface": "apple", @@ -39163,7 +39163,7 @@ }, { "kind": "ui-named-argument-multiline", - "line": 197, + "line": 199, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Play short sounds when Talk Mode switches between listening, thinking, and speaking.", "surface": "apple", @@ -39171,7 +39171,7 @@ }, { "kind": "ui-named-argument", - "line": 203, + "line": 205, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Right Option stops speech", "surface": "apple", @@ -39179,7 +39179,7 @@ }, { "kind": "ui-named-argument", - "line": 204, + "line": 206, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Tap Right Option to interrupt speech and return to listening.", "surface": "apple", @@ -39187,7 +39187,7 @@ }, { "kind": "ui-call", - "line": 209, + "line": 211, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Recognition", "surface": "apple", @@ -39195,7 +39195,7 @@ }, { "kind": "ui-call", - "line": 215, + "line": 217, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Test", "surface": "apple", @@ -39203,7 +39203,7 @@ }, { "kind": "ui-call", - "line": 315, + "line": 317, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Trigger Words", "surface": "apple", @@ -39211,7 +39211,7 @@ }, { "kind": "ui-named-argument", - "line": 317, + "line": 319, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Wake phrases", "surface": "apple", @@ -39219,7 +39219,7 @@ }, { "kind": "ui-named-argument", - "line": 318, + "line": 320, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Short phrases that start voice wake detection.", "surface": "apple", @@ -39227,7 +39227,7 @@ }, { "kind": "ui-call", - "line": 324, + "line": 326, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Add word", "surface": "apple", @@ -39235,7 +39235,7 @@ }, { "kind": "ui-call", - "line": 329, + "line": 331, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Reset", "surface": "apple", @@ -39243,7 +39243,7 @@ }, { "kind": "ui-call", - "line": 351, + "line": 353, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "No wake phrases configured", "surface": "apple", @@ -39251,7 +39251,7 @@ }, { "kind": "ui-call", - "line": 381, + "line": 383, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Sounds", "surface": "apple", @@ -39259,7 +39259,7 @@ }, { "kind": "ui-named-argument", - "line": 383, + "line": 385, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Trigger sound", "surface": "apple", @@ -39267,7 +39267,7 @@ }, { "kind": "ui-named-argument", - "line": 387, + "line": 389, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Send sound", "surface": "apple", @@ -39275,7 +39275,7 @@ }, { "kind": "ui-call", - "line": 474, + "line": 476, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "No Sound", "surface": "apple", @@ -39283,7 +39283,7 @@ }, { "kind": "ui-call", - "line": 482, + "line": 484, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Choose file…", "surface": "apple", @@ -39291,7 +39291,7 @@ }, { "kind": "ui-call", - "line": 502, + "line": 504, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Play", "surface": "apple", @@ -39299,7 +39299,7 @@ }, { "kind": "ui-named-argument", - "line": 550, + "line": 552, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Microphone", "surface": "apple", @@ -39307,7 +39307,7 @@ }, { "kind": "ui-call", - "line": 552, + "line": 554, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "System default", "surface": "apple", @@ -39315,7 +39315,7 @@ }, { "kind": "conditional-branch", - "line": 554, + "line": 556, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Unavailable", "surface": "apple", @@ -39323,7 +39323,7 @@ }, { "kind": "ui-call", - "line": 565, + "line": 567, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Disconnected (using System default)", "surface": "apple", @@ -39331,7 +39331,7 @@ }, { "kind": "ui-named-argument", - "line": 584, + "line": 586, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Recognition language", "surface": "apple", @@ -39339,7 +39339,7 @@ }, { "kind": "ui-named-argument", - "line": 585, + "line": 587, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Languages are tried in order. Models may need a first-use download on macOS 26.", "surface": "apple", @@ -39347,7 +39347,7 @@ }, { "kind": "ui-call", - "line": 587, + "line": 589, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Language", "surface": "apple", @@ -39355,7 +39355,7 @@ }, { "kind": "ui-call", - "line": 589, + "line": 591, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "\\(self.friendlyName(for: current)) (System)", "surface": "apple", @@ -39363,7 +39363,7 @@ }, { "kind": "ui-call", - "line": 601, + "line": 603, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Voice Wake needs on-device recognition for its selected language. Push-to-talk and Talk Mode remain available.", "surface": "apple", @@ -39371,7 +39371,7 @@ }, { "kind": "ui-named-argument", - "line": 613, + "line": 615, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Additional languages", "surface": "apple", @@ -39379,7 +39379,7 @@ }, { "kind": "ui-named-argument", - "line": 656, + "line": 658, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Add another language", "surface": "apple", @@ -39387,7 +39387,7 @@ }, { "kind": "ui-call", - "line": 660, + "line": 662, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Add", "surface": "apple", @@ -39395,7 +39395,7 @@ }, { "kind": "ui-named-argument", - "line": 766, + "line": 768, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Live level", "surface": "apple", @@ -39403,7 +39403,7 @@ }, { "kind": "ui-call", - "line": 840, + "line": 842, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Wake phrase", "surface": "apple", @@ -39411,7 +39411,7 @@ }, { "kind": "ui-modifier", - "line": 857, + "line": 859, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Remove trigger word", "surface": "apple", @@ -39419,7 +39419,7 @@ }, { "kind": "ui-localized-call", - "line": 881, + "line": 883, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Language %lld", "surface": "apple", @@ -39427,7 +39427,7 @@ }, { "kind": "ui-named-argument", - "line": 883, + "line": 885, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Fallback recognition language.", "surface": "apple", @@ -39435,7 +39435,7 @@ }, { "kind": "ui-call", - "line": 887, + "line": 889, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Language \\(self.index + 2)", "surface": "apple", @@ -39443,7 +39443,7 @@ }, { "kind": "ui-modifier", - "line": 904, + "line": 906, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "Remove language", "surface": "apple", @@ -39451,12 +39451,28 @@ }, { "kind": "ui-call-concatenated", - "line": 919, + "line": 921, "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", "source": "OpenClaw reacts when any trigger appears in a transcription. Keep phrases short to avoid false positives.", "surface": "apple", "id": "native.apple.237e9371a693b0ff" }, + { + "kind": "ui-named-argument", + "line": 938, + "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", + "source": "Use realtime Gateway relay", + "surface": "apple", + "id": "native.apple.31708bc96d7b1f5f" + }, + { + "kind": "ui-named-argument-multiline", + "line": 939, + "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift", + "source": "Use the Gateway's configured realtime voice session on this Mac. Requires realtime, gateway-relay, and agent-consult in Talk settings.", + "surface": "apple", + "id": "native.apple.9ef48cad7ccbf2c2" + }, { "kind": "ui-call", "line": 11, diff --git a/apps/macos/Sources/OpenClaw/AppState+Talk.swift b/apps/macos/Sources/OpenClaw/AppState+Talk.swift new file mode 100644 index 000000000000..0fa2abaff6a1 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/AppState+Talk.swift @@ -0,0 +1,10 @@ +import Foundation + +extension AppState { + func persistTalkRealtimeRelayPreference(previousValue: Bool) { + guard !self.isPreview else { return } + AppDefaults.standard.set(self.talkRealtimeRelayEnabled, forKey: talkRealtimeRelayEnabledKey) + guard self.talkEnabled, self.talkRealtimeRelayEnabled != previousValue else { return } + Task { await TalkModeRuntime.shared.realtimeRelayPreferenceDidChange() } + } +} diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 2f7bac1d01f4..73a0e9c7ba0b 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -81,7 +81,7 @@ final class AppState { private static let logger = Logger(subsystem: "ai.openclaw", category: "app-state") private static let execApprovalsReadRetryAttempts = 5 - private let isPreview: Bool + let isPreview: Bool @ObservationIgnored private let execApprovalsDefaultsAsyncResolver: @MainActor () async -> Result @ObservationIgnored private let execApprovalsReadRetryDelay: Duration @@ -258,6 +258,10 @@ final class AppState { } } + var talkRealtimeRelayEnabled: Bool { + didSet { self.persistTalkRealtimeRelayPreference(previousValue: oldValue) } + } + var talkPhaseSoundsEnabled: Bool { didSet { self.ifNotPreview { @@ -485,6 +489,7 @@ final class AppState { self.voiceWakeTriggersTalkMode = AppDefaults.standard .object(forKey: voiceWakeTriggersTalkModeKey) as? Bool ?? false self.talkEnabled = AppDefaults.standard.bool(forKey: talkEnabledKey) + self.talkRealtimeRelayEnabled = isTalkRealtimeRelayEnabled() if let storedPhaseSounds = AppDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool { self.talkPhaseSoundsEnabled = storedPhaseSounds } else { @@ -1540,6 +1545,7 @@ extension AppState { state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"] state.voicePushToTalkEnabled = false state.talkEnabled = false + state.talkRealtimeRelayEnabled = false state.talkPhaseSoundsEnabled = true state.talkShiftToStopEnabled = true state.iconOverride = .system diff --git a/apps/macos/Sources/OpenClaw/Constants.swift b/apps/macos/Sources/OpenClaw/Constants.swift index 4a76c001a576..a1c44a490dc8 100644 --- a/apps/macos/Sources/OpenClaw/Constants.swift +++ b/apps/macos/Sources/OpenClaw/Constants.swift @@ -31,6 +31,7 @@ let voiceWakeAdditionalLocalesKey = "openclaw.voiceWakeAdditionalLocaleIDs" let voicePushToTalkEnabledKey = "openclaw.voicePushToTalkEnabled" let voiceWakeTriggersTalkModeKey = "openclaw.voiceWakeTriggersTalkMode" let talkEnabledKey = "openclaw.talkEnabled" +let talkRealtimeRelayEnabledKey = "openclaw.talkRealtimeRelayEnabled" let talkPhaseSoundsEnabledKey = "openclaw.talkPhaseSoundsEnabled" let talkShiftToStopEnabledKey = "openclaw.talkShiftToStopEnabled" let iconOverrideKey = "openclaw.iconOverride" @@ -44,6 +45,10 @@ let quickChatEnabledKey = "openclaw.quickChatEnabled" let cameraEnabledKey = "openclaw.cameraEnabled" let computerControlEnabledKey = "openclaw.computerControlEnabled" +func isTalkRealtimeRelayEnabled(defaults: UserDefaults = AppDefaults.standard) -> Bool { + defaults.object(forKey: talkRealtimeRelayEnabledKey) as? Bool ?? false +} + func isComputerControlEnabled(defaults: UserDefaults = AppDefaults.standard) -> Bool { // object(forKey:) preserves an explicit false; bool(forKey:) would conflate it with an unset default. defaults.object(forKey: computerControlEnabledKey) as? Bool ?? true diff --git a/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift b/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift index 07ffd4ea6f2e..dfb6e12d0412 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift @@ -23,7 +23,7 @@ struct TalkModeGatewayConfigState { let realtimeTransport: String? let realtimeBrain: String? - var enablesMacOSRealtimeRelay: Bool { + var hasGatewayRealtimeRelayTuple: Bool { self.realtimeMode == "realtime" && self.realtimeTransport == "gateway-relay" && self.realtimeBrain == "agent-consult" diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index ec5e5fe30add..5d2ba3163619 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -85,7 +85,8 @@ actor TalkModeRuntime { private var realtimeMode: String? private var realtimeTransport: String? private var realtimeBrain: String? - private var realtimeRelayEnabled = false + private var hasGatewayRealtimeRelayTuple = false + private var macOSRealtimeRelayOptIn = false var realtimeSession: RealtimeTalkRelaySession? var realtimeSessionReadyAt: Date? var rapidRealtimeRestartCount = 0 @@ -182,6 +183,13 @@ actor TalkModeRuntime { } } + func realtimeRelayPreferenceDidChange() async { + guard self.isEnabled else { return } + self.lifecycleGeneration &+= 1 + await self.stop() + await self.start() + } + func isCurrent(_ generation: Int) -> Bool { generation == self.lifecycleGeneration && self.isEnabled } @@ -314,15 +322,28 @@ actor TalkModeRuntime { } private func shouldAttemptRealtimeRelay() -> Bool { - guard self.realtimeMode == "realtime" else { return false } - guard self.realtimeRelayEnabled else { + guard self.macOSRealtimeRelayOptIn else { + self.logger.debug("talk macOS realtime relay disabled locally; using native speech") + return false + } + guard self.hasGatewayRealtimeRelayTuple else { self.logger.warning( - "talk macOS realtime relay not explicitly enabled: " + + "talk macOS realtime relay opted in but Gateway tuple is incompatible: " + + "mode=\(self.realtimeMode ?? "missing", privacy: .public) " + "transport=\(self.realtimeTransport ?? "missing", privacy: .public) " + "brain=\(self.realtimeBrain ?? "missing", privacy: .public); using native fallback") return false } - return true + return Self.shouldUseRealtimeRelay( + localOptIn: self.macOSRealtimeRelayOptIn, + hasGatewayRealtimeRelayTuple: self.hasGatewayRealtimeRelayTuple) + } + + static func shouldUseRealtimeRelay( + localOptIn: Bool, + hasGatewayRealtimeRelayTuple: Bool) -> Bool + { + localOptIn && hasGatewayRealtimeRelayTuple } // MARK: - Speech recognition @@ -1452,7 +1473,10 @@ extension TalkModeRuntime { self.realtimeMode = cfg.realtimeMode self.realtimeTransport = cfg.realtimeTransport self.realtimeBrain = cfg.realtimeBrain - self.realtimeRelayEnabled = cfg.enablesMacOSRealtimeRelay + self.hasGatewayRealtimeRelayTuple = cfg.hasGatewayRealtimeRelayTuple + self.macOSRealtimeRelayOptIn = await MainActor.run { + AppStateStore.shared.talkRealtimeRelayEnabled + } let configuredSilenceMs = cfg.silenceTimeoutMs let locale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID } let isCJKLocale = locale.hasPrefix("ko") || locale.hasPrefix("ja") || locale.hasPrefix("zh") @@ -1483,7 +1507,8 @@ extension TalkModeRuntime { "speechLocale=\(cfg.speechLocaleID ?? "device", privacy: .public) " + "realtimeMode=\(cfg.realtimeMode ?? "off", privacy: .public) " + "realtimeTransport=\(cfg.realtimeTransport ?? "default", privacy: .public) " + - "realtimeBrain=\(cfg.realtimeBrain ?? "default", privacy: .public)") + "realtimeBrain=\(cfg.realtimeBrain ?? "default", privacy: .public) " + + "macOSRealtimeOptIn=\(self.macOSRealtimeRelayOptIn, privacy: .public)") } static func selectTalkProviderConfig( diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift index 24823e23250e..195839891861 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift @@ -182,6 +182,8 @@ struct VoiceWakeSettings: View { subtitle: "Start listening while you hold the key and show the preview overlay.", binding: self.$state.voicePushToTalkEnabled) + self.realtimeRelayToggle + if self.state.voicePushToTalkEnabled, self.state.talkEnabled { SettingsCardRow( title: "Push-to-talk paused", @@ -930,6 +932,18 @@ private struct TriggerPhraseHelpRow: View { } } +extension VoiceWakeSettings { + private var realtimeRelayToggle: some View { + SettingsCardToggleRow( + title: "Use realtime Gateway relay", + subtitle: """ + Use the Gateway's configured realtime voice session on this Mac. \ + Requires realtime, gateway-relay, and agent-consult in Talk settings. + """, + binding: self.$state.talkRealtimeRelayEnabled) + } +} + #if DEBUG struct VoiceWakeSettings_Previews: PreviewProvider { static var previews: some View { diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkModeGatewayConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkModeGatewayConfigTests.swift index 67f9f38e8b20..fdb06c06d7e7 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TalkModeGatewayConfigTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TalkModeGatewayConfigTests.swift @@ -80,7 +80,7 @@ struct TalkModeGatewayConfigTests { #expect(parsed.realtimeMode == "realtime") #expect(parsed.realtimeTransport == "gateway-relay") #expect(parsed.realtimeBrain == "agent-consult") - #expect(parsed.enablesMacOSRealtimeRelay) + #expect(parsed.hasGatewayRealtimeRelayTuple) } @Test func `realtime config infers its sole provider and reads provider defaults`() { @@ -104,7 +104,7 @@ struct TalkModeGatewayConfigTests { #expect(parsed.realtimeMode == "realtime") #expect(parsed.realtimeTransport == nil) #expect(parsed.realtimeBrain == nil) - #expect(!parsed.enablesMacOSRealtimeRelay) + #expect(!parsed.hasGatewayRealtimeRelayTuple) } @Test func `realtime provider config lookup is case insensitive`() { diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkModeRuntimeSpeechTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkModeRuntimeSpeechTests.swift index 5427338e3c8a..eed0596da7bd 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TalkModeRuntimeSpeechTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TalkModeRuntimeSpeechTests.swift @@ -88,6 +88,30 @@ private final class RuntimeTestPCMPlayer: PCMStreamingAudioPlaying { } struct TalkModeRuntimeSpeechTests { + @Test func `macOS realtime relay requires local opt in and exact Gateway tuple`() { + #expect(!TalkModeRuntime.shouldUseRealtimeRelay( + localOptIn: false, + hasGatewayRealtimeRelayTuple: false)) + #expect(!TalkModeRuntime.shouldUseRealtimeRelay( + localOptIn: false, + hasGatewayRealtimeRelayTuple: true)) + #expect(!TalkModeRuntime.shouldUseRealtimeRelay( + localOptIn: true, + hasGatewayRealtimeRelayTuple: false)) + #expect(TalkModeRuntime.shouldUseRealtimeRelay( + localOptIn: true, + hasGatewayRealtimeRelayTuple: true)) + } + + @Test @MainActor func `macOS realtime relay preference defaults off and reads explicit opt in`() async { + await TestIsolation.withUserDefaultsValues([talkRealtimeRelayEnabledKey: nil]) { + #expect(!AppState(preview: true).talkRealtimeRelayEnabled) + } + await TestIsolation.withUserDefaultsValues([talkRealtimeRelayEnabledKey: true]) { + #expect(AppState(preview: true).talkRealtimeRelayEnabled) + } + } + @Test func `speech request uses dictation defaults`() { let request = SFSpeechAudioBufferRecognitionRequest() diff --git a/docs/nodes/talk.md b/docs/nodes/talk.md index 8491b3ce6682..4d5dfb650794 100644 --- a/docs/nodes/talk.md +++ b/docs/nodes/talk.md @@ -72,6 +72,10 @@ of these together: Any other combination — including a partially set one — keeps the native path. +The Mac must also opt in locally with **Settings > Voice Wake > Use realtime Gateway relay**. +This preference defaults off and stays on that Mac; Gateway config alone never activates the +streamed path. + The Gateway must also advertise `gateway-relay` and `agent-consult` for the selected provider in `talk.catalog`. Realtime requires macOS 26 or newer, matching Voice Wake; on older versions the Talk runtime returns before startup and the native path is the only one available.