feat(talk): integrate realtime relay with macOS Talk

Co-authored-by: Zhilong Zheng <zhengzhilong1115@gmail.com>
This commit is contained in:
Vincent Koc
2026-08-21 08:38:04 -07:00
parent 0ab2f139de
commit 113a6ccdc2
13 changed files with 2764 additions and 224 deletions
+99
View File
@@ -39322,6 +39322,17 @@
}
]
},
{
"id": "native.apple.11f6f4fa597f9df4",
"source": "Realtime Talk requested an invalid audio sample rate",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/macos/Sources/OpenClaw/MacRealtimeTalkAudioCapture.swift"
}
]
},
{
"id": "native.apple.400963cb08a3c253",
"source": "Realtime Voice",
@@ -39440,6 +39451,28 @@
}
]
},
{
"id": "native.apple.48e407d88c565c02",
"source": "Realtime disconnected repeatedly — using native speech",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/macos/Sources/OpenClaw/TalkModeRuntime+Realtime.swift"
}
]
},
{
"id": "native.apple.ab73f15b8147eb41",
"source": "Realtime disconnected — reconnecting…",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/macos/Sources/OpenClaw/TalkModeRuntime+Realtime.swift"
}
]
},
{
"id": "native.apple.ca3a762269827f37",
"source": "Realtime failed",
@@ -39462,6 +39495,17 @@
}
]
},
{
"id": "native.apple.b5b843b6a0a96cc5",
"source": "Realtime microphone became unavailable: %@",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/macos/Sources/OpenClaw/MacRealtimeTalkAudioCapture.swift"
}
]
},
{
"id": "native.apple.96ece1b431ac3f96",
"source": "Realtime output cancellation failed: %@",
@@ -39495,6 +39539,17 @@
}
]
},
{
"id": "native.apple.ddb5344f621cd627",
"source": "Realtime unavailable — using native speech",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/macos/Sources/OpenClaw/TalkModeRuntime+Realtime.swift"
}
]
},
{
"id": "native.apple.4c945b4b74b4de2d",
"source": "Realtime voice did not start.",
@@ -42847,6 +42902,28 @@
}
]
},
{
"id": "native.apple.d40597f2d391f8cf",
"source": "Selected audio input has no usable Float32 format",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/macos/Sources/OpenClaw/MacRealtimeTalkAudioCapture.swift"
}
]
},
{
"id": "native.apple.8a4aa2d658ca95a3",
"source": "Selected input and system default are unavailable",
"surface": "apple",
"sites": [
{
"kind": "ui-localized-call",
"path": "apps/macos/Sources/OpenClaw/MacRealtimeTalkAudioCapture.swift"
}
]
},
{
"id": "native.apple.209377172e2dd2aa",
"source": "Selected reasoning is unavailable for this target.",
@@ -48268,6 +48345,28 @@
}
]
},
{
"id": "native.apple.050cddb7d6d24e52",
"source": "Use realtime Gateway relay",
"surface": "apple",
"sites": [
{
"kind": "ui-named-argument",
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift"
}
]
},
{
"id": "native.apple.d7c89897a6c2da7f",
"source": "Use the Gateway's configured realtime voice session on this Mac. Requires realtime, gateway-relay, and agent-consult in Talk settings.",
"surface": "apple",
"sites": [
{
"kind": "ui-named-argument-multiline",
"path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift"
}
]
},
{
"id": "native.apple.58566125ab806740",
"source": "Use the panel + Canvas",
@@ -20,6 +20,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
@@ -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() }
}
}
+5 -1
View File
@@ -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<ExecApprovalsResolvedDefaults, ExecApprovalsReadError>
@ObservationIgnored private let execApprovalsReadRetryDelay: Duration
@@ -260,6 +260,10 @@ final class AppState {
}
}
var talkRealtimeRelayEnabled = isTalkRealtimeRelayEnabled() {
didSet { self.persistTalkRealtimeRelayPreference(previousValue: oldValue) }
}
var talkPhaseSoundsEnabled: Bool {
didSet {
self.ifNotPreview {
@@ -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"
@@ -48,6 +49,10 @@ let cookieSyncEnabledKey = "openclaw.cookieSyncEnabled"
let cookieSyncIntoProfileKey = "openclaw.cookieSyncIntoProfile"
let cookieSyncDomainsKey = "openclaw.cookieSyncDomains"
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
@@ -16,6 +16,18 @@ struct TalkModeGatewayConfigState {
let referenceAudioPath: String?
let referenceText: String?
let seamColorHex: String?
let realtimeProvider: String?
let realtimeModelId: String?
let realtimeSpeakerVoice: String?
let realtimeMode: String?
let realtimeTransport: String?
let realtimeBrain: String?
var hasGatewayRealtimeRelayTuple: Bool {
self.realtimeMode == "realtime" &&
self.realtimeTransport == "gateway-relay" &&
self.realtimeBrain == "agent-consult"
}
}
enum TalkModeGatewayConfigParser {
@@ -62,6 +74,22 @@ enum TalkModeGatewayConfigParser {
.trimmingCharacters(in: .whitespacesAndNewlines)
let referenceText = activeConfig?["referenceText"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
let realtime = talk?["realtime"]?.dictionaryValue
let realtimeProviders = realtime?["providers"]?.dictionaryValue
let realtimeProvider = Self.firstString(realtime, keys: ["provider"])
?? Self.singleRealtimeProviderId(realtimeProviders)
let realtimeProviderConfig = Self.realtimeProviderConfig(
providers: realtimeProviders,
provider: realtimeProvider)
let realtimeModelId = Self.firstString(realtime, keys: ["model"])
?? Self.firstString(realtimeProviderConfig, keys: ["model"])
let realtimeSpeakerVoice = Self.firstString(
realtime,
keys: ["speakerVoice", "voice"])
?? Self.firstString(realtimeProviderConfig, keys: ["speakerVoice", "voice"])
let realtimeMode = Self.firstString(realtime, keys: ["mode"])?.lowercased()
let realtimeTransport = Self.firstString(realtime, keys: ["transport"])?.lowercased()
let realtimeBrain = Self.firstString(realtime, keys: ["brain"])?.lowercased()
let resolvedVoice: String? = if activeProvider == defaultProvider {
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
(envVoice?.isEmpty == false ? envVoice : nil) ??
@@ -90,7 +118,13 @@ enum TalkModeGatewayConfigParser {
apiKey: resolvedApiKey,
referenceAudioPath: referenceAudioPath?.isEmpty == false ? referenceAudioPath : nil,
referenceText: referenceText?.isEmpty == false ? referenceText : nil,
seamColorHex: rawSeam.isEmpty ? nil : rawSeam)
seamColorHex: rawSeam.isEmpty ? nil : rawSeam,
realtimeProvider: realtimeProvider,
realtimeModelId: realtimeModelId,
realtimeSpeakerVoice: realtimeSpeakerVoice,
realtimeMode: realtimeMode,
realtimeTransport: realtimeTransport,
realtimeBrain: realtimeBrain)
}
static func fallback(
@@ -119,6 +153,50 @@ enum TalkModeGatewayConfigParser {
apiKey: resolvedApiKey,
referenceAudioPath: nil,
referenceText: nil,
seamColorHex: nil)
seamColorHex: nil,
realtimeProvider: nil,
realtimeModelId: nil,
realtimeSpeakerVoice: nil,
realtimeMode: nil,
realtimeTransport: nil,
realtimeBrain: nil)
}
private static func firstString(
_ config: [String: AnyCodable]?,
keys: [String]) -> String?
{
guard let config else { return nil }
for key in keys {
let value = config[key]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
}
return nil
}
private static func singleRealtimeProviderId(_ providers: [String: AnyCodable]?) -> String? {
guard let providers, providers.count == 1 else { return nil }
let provider = providers.keys.first?.trimmingCharacters(in: .whitespacesAndNewlines)
return provider?.isEmpty == false ? provider : nil
}
private static func realtimeProviderConfig(
providers: [String: AnyCodable]?,
provider: String?) -> [String: AnyCodable]?
{
guard let providers else { return nil }
if let provider {
if let exact = providers[provider]?.dictionaryValue {
return exact
}
return providers.first { key, _ in
key.trimmingCharacters(in: .whitespacesAndNewlines)
.caseInsensitiveCompare(provider) == .orderedSame
}?.value.dictionaryValue
}
if providers.count == 1 {
return providers.values.first?.dictionaryValue
}
return nil
}
}
@@ -0,0 +1,764 @@
import Foundation
import OpenClawChatUI
import OpenClawKit
import OSLog
private enum RealtimeRelayConfigurationError: LocalizedError {
case incompatible
var errorDescription: String? {
"Gateway realtime Talk configuration changed before startup"
}
}
extension TalkModeRuntime {
#if DEBUG
typealias VoiceWakePermissionProvider = @Sendable () async -> Bool
#endif
private enum ScheduledRealtimeRecoveryState: Equatable {
case cancelled
case waitingForStartToFinish
case ready
}
private static let realtimeStableSessionSeconds: TimeInterval = 30
private static let realtimeRestartDelaysNanoseconds: [UInt64] = [500_000_000, 2_000_000_000]
static func realtimeRestartAttempt(
previousRapidRestarts: Int,
activeDuration: TimeInterval) -> Int
{
activeDuration >= self.realtimeStableSessionSeconds ? 1 : previousRapidRestarts + 1
}
static func realtimeRestartDelayNanoseconds(attempt: Int) -> UInt64? {
guard attempt > 0, attempt <= self.realtimeRestartDelaysNanoseconds.count else { return nil }
return self.realtimeRestartDelaysNanoseconds[attempt - 1]
}
func stop(
reconfigurationGeneration expectedReconfigurationGeneration: UInt64?,
lifecycleGeneration expectedLifecycleGeneration: Int?) async
{
guard self.ownsReconfiguration(
expectedReconfigurationGeneration,
lifecycleGeneration: expectedLifecycleGeneration)
else { return }
self.pendingRealtimeRelayStartLifecycleGeneration = nil
self.resetRealtimeRecoveryState()
self.realtimeRelayGeneration &+= 1
self.realtimeRelayStartGeneration = nil
let realtimeSession = self.detachResourcesForRealtimeStop()
if let realtimeSession {
await MainActor.run { realtimeSession.stop() }
}
guard self.ownsReconfiguration(
expectedReconfigurationGeneration,
lifecycleGeneration: expectedLifecycleGeneration)
else { return }
await stopSpeaking(
reason: .manual,
reconfigurationGeneration: expectedReconfigurationGeneration,
lifecycleGeneration: expectedLifecycleGeneration)
guard self.ownsReconfiguration(
expectedReconfigurationGeneration,
lifecycleGeneration: expectedLifecycleGeneration)
else { return }
let projectionGeneration = self.realtimeRelayGeneration
_ = await MainActor.run {
self.realtimeRelayDeliveryGate.deliver(ifActive: projectionGeneration) {
TalkModeController.shared.updateLevel(0)
TalkModeController.shared.updatePartialTranscript("")
TalkModeController.shared.updatePhase(.idle)
}
}
}
func ownsReconfiguration(
_ expectedReconfigurationGeneration: UInt64?,
lifecycleGeneration expectedLifecycleGeneration: Int?) -> Bool
{
(expectedReconfigurationGeneration.map { $0 == self.realtimeReconfigurationGeneration } ?? true) &&
(expectedLifecycleGeneration.map { $0 == self.lifecycleGeneration } ?? true)
}
func beginRealtimeReconfiguration() -> (generation: UInt64, lifecycleGeneration: Int) {
self.lifecycleGeneration &+= 1
self.realtimeReconfigurationGeneration &+= 1
return (self.realtimeReconfigurationGeneration, self.lifecycleGeneration)
}
func realtimeRelayPreferenceDidChange() async {
guard isEnabled else { return }
let reconfiguration = self.beginRealtimeReconfiguration()
await self.stop(
reconfigurationGeneration: reconfiguration.generation,
lifecycleGeneration: reconfiguration.lifecycleGeneration)
guard self.isCurrent(reconfiguration.lifecycleGeneration) else { return }
await self.start()
}
func start() async {
let gen = lifecycleGeneration
#if DEBUG
guard self.voiceWakeSupportedProvider() else { return }
let hasVoiceWakePermission = await self.voiceWakePermissionProvider()
#else
guard voiceWakeSupported else { return }
let hasVoiceWakePermission = await PermissionManager.ensureVoiceWakePermissions(interactive: true)
#endif
guard hasVoiceWakePermission else {
logger.error("talk runtime not starting: permissions missing")
return
}
macOSRealtimeRelayOptIn = await MainActor.run {
AppStateStore.shared.talkRealtimeRelayEnabled
}
let bypassRealtime = bypassRealtimeOnNextStart
bypassRealtimeOnNextStart = false
if !macOSRealtimeRelayOptIn || bypassRealtime {
await reloadConfig()
}
guard isCurrent(gen) else { return }
if isPaused {
phase = .idle
await MainActor.run {
TalkModeController.shared.updateLevel(0)
TalkModeController.shared.updatePhase(.idle)
}
return
}
var nativeFallbackStatus: String?
if self.macOSRealtimeRelayOptIn, !bypassRealtime {
let fallbackRecognitionGeneration = recognitionGeneration
let fallbackRealtimeRelayGeneration = realtimeRelayGeneration &+ 1
do {
try await self.startRealtimeRelay(generation: gen)
return
} catch is CancellationError {
if self.consumePendingRealtimeRelayStart() {
await self.start()
}
return
} catch {
pendingRealtimeRelayStartLifecycleGeneration = nil
guard isCurrent(gen), !isPaused,
recognitionGeneration == fallbackRecognitionGeneration,
realtimeRelayGeneration == fallbackRealtimeRelayGeneration,
realtimeRelayStartGeneration == nil,
realtimeSession == nil
else { return }
logger.error(
"talk realtime unavailable; using native fallback: " +
"\(error.localizedDescription, privacy: .public)")
nativeFallbackStatus = String(localized: "Realtime unavailable — using native speech")
}
}
await self.startNativeFallback(generation: gen, status: nativeFallbackStatus)
}
func consumePendingRealtimeRelayStart() -> Bool {
guard let generation = pendingRealtimeRelayStartLifecycleGeneration else { return false }
pendingRealtimeRelayStartLifecycleGeneration = nil
return generation == lifecycleGeneration && isEnabled && !isPaused &&
realtimeSession == nil && realtimeRelayStartGeneration == nil &&
self.macOSRealtimeRelayOptIn
}
private func startNativeFallback(generation: Int, status: String? = nil) async {
let relayGeneration = realtimeRelayGeneration
guard await startRecognition(lifecycleGeneration: generation),
await self.commitNativeFallback(
lifecycleGeneration: generation,
recognitionGeneration: recognitionGeneration,
relayGeneration: relayGeneration,
status: status)
else { return }
startAudioInputObserver()
startSilenceMonitor()
}
func commitNativeFallback(
lifecycleGeneration: Int,
recognitionGeneration: Int,
relayGeneration: UInt64,
status: String?) async -> Bool
{
let ownsFallback = {
self.canCommitRecognitionStart(
lifecycleGeneration: lifecycleGeneration,
recognitionAttempt: recognitionGeneration) &&
self.realtimeRelayGeneration == relayGeneration &&
self.realtimeRelayStartGeneration == nil && self.realtimeSession == nil
}
guard ownsFallback() else { return false }
phase = .listening
return await self.projectRealtimeRelay(relayGeneration, nil) {
if let status {
TalkModeController.shared.updatePartialTranscript(status)
}
TalkModeController.shared.updatePhase(.listening)
}
}
func inputDeviceSelectionDidChange() async {
if let realtimeSession {
guard isEnabled, !isPaused else { return }
let relayGeneration = realtimeRelayGeneration
do {
try await MainActor.run {
try realtimeSession.setInputPaused(true)
try realtimeSession.setInputPaused(false)
}
} catch {
logger.error(
"talk realtime input restart failed: \(error.localizedDescription, privacy: .public)")
await self.handleRealtimeInputRestartFailure(
error.localizedDescription,
relayGeneration: relayGeneration)
}
return
}
guard isEnabled, !isPaused, phase == .listening else { return }
logger.info("talk input selection changed; restarting capture")
let lifecycleGeneration = self.lifecycleGeneration
_ = await startRecognition(lifecycleGeneration: lifecycleGeneration)
}
func shouldAttemptRealtimeRelay() -> Bool {
guard macOSRealtimeRelayOptIn else {
logger.debug("talk macOS realtime relay disabled locally; using native speech")
return false
}
guard hasGatewayRealtimeRelayTuple else {
logger.warning(
"talk macOS realtime relay opted in but Gateway tuple is incompatible: " +
"mode=\(realtimeMode ?? "missing", privacy: .public) " +
"transport=\(realtimeTransport ?? "missing", privacy: .public) " +
"brain=\(realtimeBrain ?? "missing", privacy: .public); using native fallback")
return false
}
return Self.shouldUseRealtimeRelay(
localOptIn: macOSRealtimeRelayOptIn,
hasGatewayRealtimeRelayTuple: hasGatewayRealtimeRelayTuple)
}
static func shouldUseRealtimeRelay(
localOptIn: Bool,
hasGatewayRealtimeRelayTuple: Bool) -> Bool
{
localOptIn && hasGatewayRealtimeRelayTuple
}
func startRealtimeRelay(generation: Int) async throws {
let relayGeneration = try beginRealtimeRelayStart()
defer {
if self.realtimeRelayStartGeneration == relayGeneration {
self.realtimeRelayStartGeneration = nil
}
}
let bootstrap: GatewayConnection.RealtimeTalkBootstrap
do {
bootstrap = try await self.realtimeTalkBootstrapProvider()
} catch {
try await self.applyRealtimeTalkConfig(
self.fallbackTalkConfig(),
lifecycleGeneration: generation,
relayGeneration: relayGeneration)
throw error
}
guard isCurrent(generation), !isPaused,
realtimeRelayGeneration == relayGeneration
else { throw CancellationError() }
let config = self.parseTalkConfig(bootstrap.configSnapshot)
try await self.applyRealtimeTalkConfig(
config,
lifecycleGeneration: generation,
relayGeneration: relayGeneration)
guard self.shouldAttemptRealtimeRelay() else {
throw RealtimeRelayConfigurationError.incompatible
}
let session = try await makeRealtimeRelaySession(
bootstrap: bootstrap,
lifecycleGeneration: generation,
relayGeneration: relayGeneration)
try await ownAndStartRealtimeSession(
session,
lifecycleGeneration: generation,
relayGeneration: relayGeneration,
start: { session in try await session.start() })
realtimeSessionReadyAt = Date()
phase = .listening
_ = await self.projectRealtimeRelay(relayGeneration, session) {
TalkModeController.shared.updatePartialTranscript("")
TalkModeController.shared.updatePhase(.listening)
}
logger.info(
"talk realtime ready provider=\(realtimeProvider ?? "default", privacy: .public) " +
"model=\(realtimeModelId ?? "default", privacy: .public)")
}
func applyRealtimeTalkConfig(
_ config: TalkModeGatewayConfigState,
lifecycleGeneration: Int,
relayGeneration: UInt64) async throws
{
let locale = await MainActor.run { () -> String? in
guard self.realtimeRelayDeliveryGate.deliver(ifActive: relayGeneration, {
AppStateStore.shared.seamColorHex = config.seamColorHex
}) else { return nil }
return AppStateStore.shared.voiceWakeLocaleID
}
#if DEBUG
if let checkpoint = self.realtimeConfigApplicationCheckpoint {
await checkpoint()
}
#endif
guard let locale,
self.isCurrent(lifecycleGeneration),
!self.isPaused,
self.realtimeRelayGeneration == relayGeneration,
self.realtimeRelayStartGeneration == relayGeneration
else { throw CancellationError() }
self.commitTalkConfig(config, locale: locale)
}
func fallbackTalkConfig() -> TalkModeGatewayConfigState {
let env = ProcessInfo.processInfo.environment
return TalkModeGatewayConfigParser.fallback(
defaultModelIdFallback: Self.defaultModelIdFallback,
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs,
envVoice: env["ELEVENLABS_VOICE_ID"],
sagVoice: env["SAG_VOICE_ID"],
envApiKey: env["ELEVENLABS_API_KEY"])
}
private func beginRealtimeRelayStart() throws -> UInt64 {
guard realtimeSession == nil, realtimeRelayStartGeneration == nil else {
throw CancellationError()
}
realtimeRelayGeneration &+= 1
let relayGeneration = realtimeRelayGeneration
realtimeRelayStartGeneration = relayGeneration
return relayGeneration
}
private func makeRealtimeRelaySession(
bootstrap: GatewayConnection.RealtimeTalkBootstrap,
lifecycleGeneration: Int,
relayGeneration: UInt64) async throws -> RealtimeTalkRelaySession
{
guard isCurrent(lifecycleGeneration), !isPaused,
realtimeRelayGeneration == relayGeneration
else { throw CancellationError() }
let activeSessionKey = await MainActor.run {
WebChatManager.shared.activeSessionKey
}
let sessionKey: String = if let activeSessionKey {
activeSessionKey
} else {
bootstrap.sessionKey
}
let options = RealtimeTalkRelaySession.Options(
sessionKey: sessionKey,
provider: realtimeProvider,
model: realtimeModelId,
voice: realtimeSpeakerVoice)
return await MainActor.run {
RealtimeTalkRelaySession(
transport: bootstrap.transport,
options: options,
audioCapture: MacRealtimeTalkAudioCapture(),
pcmPlayer: RealtimePCMStreamingAudioPlayer(),
onStatus: { [weak self] status in
Task { await self?.handleRealtimeStatus(status, relayGeneration: relayGeneration) }
},
onIssue: { [weak self] issue in
Task { await self?.handleRealtimeIssue(issue, relayGeneration: relayGeneration) }
},
onTermination: { [weak self] termination in
Task {
await self?.handleRealtimeTermination(
termination,
relayGeneration: relayGeneration)
}
},
onSpeakingChanged: { [weak self] speaking in
Task {
await self?.handleRealtimeSpeakingChanged(
speaking,
relayGeneration: relayGeneration)
}
},
onInputLevel: { [weak self] level in
Task { await self?.handleRealtimeInputLevel(level, relayGeneration: relayGeneration) }
},
onOutputLevel: { [weak self] level in
Task { await self?.handleRealtimeOutputLevel(level, relayGeneration: relayGeneration) }
},
onTranscript: { [weak self] transcript in
Task {
await self?.handleRealtimeTranscript(
transcript,
relayGeneration: relayGeneration)
}
})
}
}
private func ownAndStartRealtimeSession(
_ session: RealtimeTalkRelaySession,
lifecycleGeneration: Int,
relayGeneration: UInt64,
start: @MainActor @Sendable (RealtimeTalkRelaySession) async throws -> Void) async throws
{
// Construction crosses executors. Claim ownership only after every lifecycle and
// attempt fact is revalidated, then publish before start can suspend.
guard isCurrent(lifecycleGeneration), !isPaused,
realtimeRelayGeneration == relayGeneration,
realtimeRelayStartGeneration == relayGeneration,
realtimeSession == nil
else {
await MainActor.run { session.stop() }
throw CancellationError()
}
realtimeSession = session
do {
try await start(session)
} catch {
await MainActor.run { session.stop() }
if realtimeSession === session {
realtimeSession = nil
}
guard isCurrent(lifecycleGeneration), !isPaused,
realtimeRelayGeneration == relayGeneration,
realtimeRelayStartGeneration == relayGeneration
else {
throw CancellationError()
}
throw error
}
guard isCurrent(lifecycleGeneration), !isPaused,
realtimeRelayGeneration == relayGeneration,
realtimeSession === session
else {
await MainActor.run { session.stop() }
if realtimeSession === session {
realtimeSession = nil
}
throw CancellationError()
}
}
private func handleRealtimeStatus(_ status: String, relayGeneration: UInt64) {
guard let session = realtimeSession,
ownsRealtimeRelay(relayGeneration, session)
else { return }
logger.debug("talk realtime status=\(status, privacy: .public)")
}
private func handleRealtimeIssue(_ issue: RealtimeTalkRelayIssue, relayGeneration: UInt64) async {
guard let session = realtimeSession,
ownsRealtimeRelay(relayGeneration, session)
else { return }
logger.error(
"talk realtime issue code=\(issue.code, privacy: .public) " +
"message=\(issue.message, privacy: .public)")
_ = await self.projectRealtimeRelay(relayGeneration, session) { TalkModeController.shared
.updatePartialTranscript(issue.message)
}
}
func handleRealtimeInputRestartFailure(
_ message: String,
relayGeneration: UInt64) async
{
let issue = RealtimeTalkRelayIssue(
code: "audio_input_unavailable",
message: message,
provider: realtimeProvider,
model: realtimeModelId,
transport: "gateway-relay",
phase: "audio-input")
await handleRealtimeIssue(issue, relayGeneration: relayGeneration)
await handleRealtimeTermination(
.audioInputFailed(message: issue.message),
relayGeneration: relayGeneration)
}
func setRealtimeInputPaused(
_ paused: Bool,
session: RealtimeTalkRelaySession,
relayGeneration: UInt64) async -> Bool
{
do {
try await MainActor.run {
try session.setInputPaused(paused)
}
return true
} catch {
logger.error(
"talk realtime pause transition failed: \(error.localizedDescription, privacy: .public)")
await self.handleRealtimeInputRestartFailure(
error.localizedDescription,
relayGeneration: relayGeneration)
return false
}
}
func handleRealtimeTermination(
_ termination: RealtimeTalkRelayTermination,
relayGeneration: UInt64) async
{
guard let session = realtimeSession,
ownsRealtimeRelay(relayGeneration, session)
else { return }
logger.warning(
"talk realtime terminated=\(String(describing: termination), privacy: .public)")
let activeDuration = realtimeSessionReadyAt.map { Date().timeIntervalSince($0) } ?? 0
realtimeRelayGeneration &+= 1
let terminalGeneration = realtimeRelayGeneration
// Session-owned terminations close before signalling; runtime-initiated ones do not.
// stop() is idempotent, so closing here keeps a dead relay and its event subscription
// from outliving their owner while recovery starts a replacement session.
await MainActor.run { session.stop() }
guard self.ownsRealtimeRelay(terminalGeneration, session) else { return }
realtimeSession = nil
realtimeSessionReadyAt = nil
phase = .idle
let shouldRecover = isEnabled && !isPaused
let attempt = Self.realtimeRestartAttempt(
previousRapidRestarts: rapidRealtimeRestartCount,
activeDuration: activeDuration)
let delay = Self.realtimeRestartDelayNanoseconds(attempt: attempt)
guard await self.projectRealtimeRelay(terminalGeneration, nil, {
TalkModeController.shared.updateLevel(0)
TalkModeController.shared.updateSpeakingLevel(nil)
TalkModeController.shared.updatePhase(.idle)
if shouldRecover {
TalkModeController.shared.updatePartialTranscript(delay == nil
? String(localized: "Realtime disconnected repeatedly — using native speech")
: String(localized: "Realtime disconnected — reconnecting…"))
}
}) else { return }
let lifecycleGeneration = self.lifecycleGeneration
let restartGeneration = realtimeRestartGeneration &+ 1
guard shouldRecover, self.ownsRealtimeRelay(terminalGeneration, nil),
isEnabled, !isPaused
else { return }
rapidRealtimeRestartCount = attempt
realtimeRestartGeneration = restartGeneration
bypassRealtimeOnNextStart = delay == nil
self.scheduleRealtimeRecovery(
after: delay,
lifecycleGeneration: lifecycleGeneration,
restartGeneration: restartGeneration)
}
func handleRealtimeSpeakingChanged(_ speaking: Bool, relayGeneration: UInt64) async {
guard let session = realtimeSession,
ownsRealtimeRelay(relayGeneration, session),
isEnabled,
!self.isPaused
else { return }
if speaking {
phase = .speaking
_ = await self.projectRealtimeRelay(relayGeneration, session) {
TalkModeController.shared.updatePhase(.speaking)
}
} else if !isPaused {
phase = .listening
_ = await self.projectRealtimeRelay(relayGeneration, session) {
TalkModeController.shared.updatePhase(.listening)
}
}
}
func handleRealtimeInputLevel(_ level: Double, relayGeneration: UInt64) async {
guard let session = realtimeSession,
ownsRealtimeRelay(relayGeneration, session),
isEnabled,
!self.isPaused
else { return }
_ = await self.projectRealtimeRelay(relayGeneration, session) {
TalkModeController.shared.updateLevel(level)
}
}
func handleRealtimeOutputLevel(_ level: Double?, relayGeneration: UInt64) async {
guard let session = realtimeSession,
ownsRealtimeRelay(relayGeneration, session),
isEnabled,
!self.isPaused
else { return }
_ = await self.projectRealtimeRelay(relayGeneration, session) {
TalkModeController.shared.updateSpeakingLevel(level)
}
}
func handleRealtimeTranscript(
_ transcript: RealtimeTalkTranscript,
relayGeneration: UInt64) async
{
guard let session = realtimeSession,
ownsRealtimeRelay(relayGeneration, session),
isEnabled,
!self.isPaused
else { return }
let text = transcript.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
guard transcript.role == "user" else { return }
if transcript.isFinal {
phase = .thinking
_ = await self.projectRealtimeRelay(relayGeneration, session) {
TalkModeController.shared.commitTranscript(text)
TalkModeController.shared.updatePhase(.thinking)
}
} else {
_ = await self.projectRealtimeRelay(relayGeneration, session) {
TalkModeController.shared.updatePartialTranscript(text)
}
}
}
func ownsRealtimeRelay(_ generation: UInt64, _ session: RealtimeTalkRelaySession?) -> Bool {
realtimeRelayGeneration == generation && realtimeSession === session
}
func projectRealtimeRelay(
_ generation: UInt64,
_ session: RealtimeTalkRelaySession?,
_ body: @escaping @MainActor @Sendable () -> Void) async -> Bool
{
guard self.ownsRealtimeRelay(generation, session) else { return false }
return await MainActor.run {
self.realtimeRelayDeliveryGate.deliver(ifActive: generation, body)
}
}
func resetRealtimeRecoveryState() {
self.cancelScheduledRealtimeRecovery()
realtimeSessionReadyAt = nil
rapidRealtimeRestartCount = 0
bypassRealtimeOnNextStart = false
}
func cancelScheduledRealtimeRecovery() {
realtimeRestartGeneration &+= 1
realtimeRestartTask?.cancel()
realtimeRestartTask = nil
}
private func scheduleRealtimeRecovery(
after delayNanoseconds: UInt64?,
lifecycleGeneration: Int,
restartGeneration: UInt64)
{
realtimeRestartTask?.cancel()
realtimeRestartTask = Task { [weak self] in
if let delayNanoseconds {
do {
try await Task.sleep(nanoseconds: delayNanoseconds)
} catch {
return
}
}
while let self {
switch await self.scheduledRealtimeRecoveryState(
lifecycleGeneration: lifecycleGeneration,
restartGeneration: restartGeneration)
{
case .cancelled:
return
case .waitingForStartToFinish:
do {
try await Task.sleep(nanoseconds: 50_000_000)
} catch {
return
}
case .ready:
await self.performScheduledRealtimeRecovery(
lifecycleGeneration: lifecycleGeneration,
restartGeneration: restartGeneration)
return
}
}
}
}
private func scheduledRealtimeRecoveryState(
lifecycleGeneration: Int,
restartGeneration: UInt64) -> ScheduledRealtimeRecoveryState
{
guard self.lifecycleGeneration == lifecycleGeneration,
realtimeRestartGeneration == restartGeneration,
isEnabled,
!isPaused,
realtimeSession == nil
else { return .cancelled }
return realtimeRelayStartGeneration == nil ? .ready : .waitingForStartToFinish
}
private func performScheduledRealtimeRecovery(
lifecycleGeneration: Int,
restartGeneration: UInt64) async
{
guard self.scheduledRealtimeRecoveryState(
lifecycleGeneration: lifecycleGeneration,
restartGeneration: restartGeneration) == .ready
else { return }
realtimeRestartTask = nil
await self.start()
}
}
#if DEBUG
extension TalkModeRuntime {
func _test_beginRecognitionAttempt(lifecycleGeneration: Int) -> Int? {
self.beginRecognitionAttempt(lifecycleGeneration: lifecycleGeneration)
}
func _test_setRecognitionCleanupProbe(_ probe: (@Sendable () -> Void)?) {
self.recognitionCleanupProbe = probe
}
func _test_setVoiceWakeReadiness(supported: Bool, permissionGranted: Bool) {
self.voiceWakeSupportedProvider = { supported }
self.voiceWakePermissionProvider = { permissionGranted }
}
func _test_setRealtimeConfigApplicationCheckpoint(
_ checkpoint: (@Sendable () async -> Void)?)
{
self.realtimeConfigApplicationCheckpoint = checkpoint
}
func _test_enableRealtimeRelaySelection() {
(macOSRealtimeRelayOptIn, hasGatewayRealtimeRelayTuple) = (true, true)
}
func _test_prepareEnabledLifecycle() -> Int {
isEnabled = true
isPaused = false
lifecycleGeneration &+= 1
return lifecycleGeneration
}
func _test_prepareEnabledRealtimeSessionForClose(
_ session: RealtimeTalkRelaySession) -> UInt64
{
self.cancelScheduledRealtimeRecovery()
isEnabled = true
isPaused = false
lifecycleGeneration &+= 1
realtimeRelayGeneration &+= 1
realtimeSession = session
realtimeSessionReadyAt = nil
rapidRealtimeRestartCount = 0
bypassRealtimeOnNextStart = false
return realtimeRelayGeneration
}
}
#endif
+383 -219
View File
@@ -9,6 +9,8 @@ import Speech
actor TalkModeRuntime {
static let shared = TalkModeRuntime()
typealias RealtimeTalkBootstrapProvider =
@Sendable () async throws -> GatewayConnection.RealtimeTalkBootstrap
enum PlaybackPlan: Equatable {
case elevenLabsThenSystemVoice(apiKey: String, voiceId: String)
@@ -22,13 +24,13 @@ actor TalkModeRuntime {
case fallback
}
private let logger = Logger(subsystem: "ai.openclaw", category: "talk.runtime")
private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts")
private static let defaultModelIdFallback = "eleven_v3"
private static let defaultTalkProvider = "elevenlabs"
private static let mlxTalkProvider = "mlx"
private static let systemTalkProvider = "system"
private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs
let logger = Logger(subsystem: "ai.openclaw", category: "talk.runtime")
let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts")
static let defaultModelIdFallback = "eleven_v3"
static let defaultTalkProvider = "elevenlabs"
static let mlxTalkProvider = "mlx"
static let systemTalkProvider = "system"
static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs
private final class RMSMeter: @unchecked Sendable {
private let lock = NSLock()
@@ -54,16 +56,16 @@ actor TalkModeRuntime {
private var activeInputResolution: AudioInputDeviceResolution?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private var recognitionGeneration: Int = 0
var recognitionGeneration: Int = 0
private var rmsTask: Task<Void, Never>?
private let rmsMeter = RMSMeter()
private var captureTask: Task<Void, Never>?
private var silenceTask: Task<Void, Never>?
private var phase: TalkModePhase = .idle
private var isEnabled = false
private var isPaused = false
private var lifecycleGeneration: Int = 0
var phase: TalkModePhase = .idle
var isEnabled = false
var isPaused = false
var lifecycleGeneration: Int = 0
private var lastHeard: Date?
private var noiseFloorRMS: Double = 1e-4
@@ -79,6 +81,38 @@ actor TalkModeRuntime {
private var defaultOutputFormat: String?
private var interruptOnSpeech: Bool = true
private var activeTalkProvider = TalkModeRuntime.defaultTalkProvider
var realtimeProvider: String?
var realtimeModelId: String?
var realtimeSpeakerVoice: String?
var realtimeMode: String?
var realtimeTransport: String?
var realtimeBrain: String?
var hasGatewayRealtimeRelayTuple = false
var macOSRealtimeRelayOptIn = false
var realtimeSession: RealtimeTalkRelaySession?
var realtimeSessionReadyAt: Date?
var rapidRealtimeRestartCount = 0
var bypassRealtimeOnNextStart = false
let realtimeRelayDeliveryGate = TalkGenerationDeliveryGate()
var realtimeRelayGeneration: UInt64 = 0 {
didSet { _ = self.realtimeRelayDeliveryGate.activate() }
}
var realtimeRelayStartGeneration: UInt64?
var pendingRealtimeRelayStartLifecycleGeneration: Int?
var realtimeRestartGeneration: UInt64 = 0
var realtimeRestartTask: Task<Void, Never>?
var realtimeReconfigurationGeneration: UInt64 = 0
let realtimeTalkBootstrapProvider: RealtimeTalkBootstrapProvider
#if DEBUG
var voiceWakeSupportedProvider: @Sendable () -> Bool = { voiceWakeSupported }
var voiceWakePermissionProvider: VoiceWakePermissionProvider = {
await PermissionManager.ensureVoiceWakePermissions(interactive: true)
}
var realtimeConfigApplicationCheckpoint: (@Sendable () async -> Void)?
var recognitionCleanupProbe: (@Sendable () -> Void)?
#endif
private var speechLocaleID: String?
private var lastInterruptedAtSeconds: Double?
private var voiceAliases: [String: String] = [:]
@@ -93,8 +127,10 @@ actor TalkModeRuntime {
private let minSpeechRMS: Double = 1e-3
private let speechBoostFactor: Double = 6.0
static func configureRecognitionRequest(_ request: SFSpeechAudioBufferRecognitionRequest) {
SpeechRecognitionRequestPolicy.configureInteractiveTranscription(request)
init(realtimeTalkBootstrapProvider: @escaping RealtimeTalkBootstrapProvider = {
try await GatewayConnection.shared.acquireRealtimeTalkBootstrap()
}) {
self.realtimeTalkBootstrapProvider = realtimeTalkBootstrapProvider
}
// MARK: - Lifecycle
@@ -103,8 +139,9 @@ actor TalkModeRuntime {
guard enabled != self.isEnabled else { return }
self.isEnabled = enabled
self.lifecycleGeneration &+= 1
resetRealtimeRecoveryState()
if enabled {
await self.start()
await start()
} else {
await self.stop()
}
@@ -117,83 +154,117 @@ actor TalkModeRuntime {
guard self.isEnabled else { return }
if paused {
self.pendingRealtimeRelayStartLifecycleGeneration = nil
if self.realtimeRelayStartGeneration != nil {
self.realtimeRelayGeneration &+= 1
}
} else if self.realtimeRelayStartGeneration != nil, self.macOSRealtimeRelayOptIn {
self.pendingRealtimeRelayStartLifecycleGeneration = self.lifecycleGeneration
return
}
if paused, realtimeSession == nil {
cancelScheduledRealtimeRecovery()
}
if let realtimeSession {
let relayGeneration = self.realtimeRelayGeneration
if paused {
await MainActor.run { realtimeSession.setOutputPaused(true) }
guard self.isPaused,
self.realtimeRelayGeneration == relayGeneration,
self.realtimeSession === realtimeSession,
await setRealtimeInputPaused(
true,
session: realtimeSession,
relayGeneration: relayGeneration)
else { return }
self.lastTranscript = ""
self.lastHeard = nil
self.lastSpeechEnergyAt = nil
self.phase = .idle
_ = await projectRealtimeRelay(relayGeneration, realtimeSession) {
TalkModeController.shared.updateLevel(0)
TalkModeController.shared.updateSpeakingLevel(nil)
TalkModeController.shared.updatePartialTranscript("")
TalkModeController.shared.updatePhase(.idle)
}
} else {
guard await setRealtimeInputPaused(
false,
session: realtimeSession,
relayGeneration: relayGeneration),
!self.isPaused,
self.realtimeRelayGeneration == relayGeneration,
self.realtimeSession === realtimeSession
else { return }
await MainActor.run { realtimeSession.setOutputPaused(false) }
guard !self.isPaused,
self.realtimeRelayGeneration == relayGeneration,
self.realtimeSession === realtimeSession
else {
await MainActor.run { realtimeSession.setOutputPaused(true) }
return
}
self.phase = .listening
_ = await projectRealtimeRelay(relayGeneration, realtimeSession) {
TalkModeController.shared.updatePhase(.listening)
}
}
return
}
if !paused, self.macOSRealtimeRelayOptIn {
await start()
return
}
if paused {
self.lastTranscript = ""
self.lastHeard = nil
self.lastSpeechEnergyAt = nil
await MainActor.run { TalkModeController.shared.updatePartialTranscript("") }
await self.stopRecognition()
self.stopRecognition()
return
}
if self.phase == .idle || self.phase == .listening {
await self.startRecognition()
let lifecycleGeneration = self.lifecycleGeneration
guard await self.startRecognition(lifecycleGeneration: lifecycleGeneration),
self.isCurrent(lifecycleGeneration), !self.isPaused else { return }
self.phase = .listening
await MainActor.run { TalkModeController.shared.updatePhase(.listening) }
self.startSilenceMonitor()
}
}
private func isCurrent(_ generation: Int) -> Bool {
func isCurrent(_ generation: Int) -> Bool {
generation == self.lifecycleGeneration && self.isEnabled
}
private func start() async {
let gen = self.lifecycleGeneration
guard voiceWakeSupported else { return }
guard await PermissionManager.ensureVoiceWakePermissions(interactive: true) else {
self.logger.error("talk runtime not starting: permissions missing")
return
}
self.startAudioInputObserver()
await reloadConfig()
guard self.isCurrent(gen) else { return }
if self.isPaused {
self.phase = .idle
await MainActor.run {
TalkModeController.shared.updateLevel(0)
TalkModeController.shared.updatePhase(.idle)
}
return
}
await self.startRecognition()
guard self.isCurrent(gen) else { return }
self.phase = .listening
await MainActor.run { TalkModeController.shared.updatePhase(.listening) }
self.startSilenceMonitor()
func stop() async {
await self.stop(reconfigurationGeneration: nil, lifecycleGeneration: nil)
}
private func stop() async {
func detachResourcesForRealtimeStop() -> RealtimeTalkRelaySession? {
let realtimeSession = self.realtimeSession
self.realtimeSession = nil
self.audioInputObserver?.stop()
self.audioInputObserver = nil
self.captureTask?.cancel()
self.captureTask = nil
self.silenceTask?.cancel()
self.silenceTask = nil
// Stop audio before changing phase (stopSpeaking is gated on .speaking).
await stopSpeaking(reason: .manual)
self.lastTranscript = ""
self.lastHeard = nil
self.lastSpeechEnergyAt = nil
self.phase = .idle
await self.stopRecognition()
await MainActor.run {
TalkModeController.shared.updateLevel(0)
TalkModeController.shared.updatePartialTranscript("")
TalkModeController.shared.updatePhase(.idle)
}
self.stopRecognition()
return realtimeSession
}
func inputDeviceSelectionDidChange() async {
guard self.isEnabled, !self.isPaused, self.phase == .listening else { return }
self.logger.info("talk input selection changed; restarting capture")
await self.startRecognition()
}
private func startAudioInputObserver() {
func startAudioInputObserver() {
guard self.audioInputObserver == nil else { return }
let observer = AudioInputDeviceObserver()
observer.start {
@@ -204,9 +275,10 @@ actor TalkModeRuntime {
private func audioInputDevicesDidChange() async {
guard self.isEnabled, !self.isPaused, self.phase == .listening else { return }
let lifecycleGeneration = self.lifecycleGeneration
let availableUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
guard let activeInputResolution else {
await self.startRecognition()
_ = await self.startRecognition(lifecycleGeneration: lifecycleGeneration)
return
}
guard activeInputResolution.shouldRestart(
@@ -215,7 +287,7 @@ actor TalkModeRuntime {
else { return }
self.logger.warning("talk active/default input changed; restarting capture")
await self.startRecognition()
_ = await self.startRecognition(lifecycleGeneration: lifecycleGeneration)
}
// MARK: - Speech recognition
@@ -228,12 +300,13 @@ actor TalkModeRuntime {
let generation: Int
}
private func startRecognition() async {
await self.stopRecognition()
self.recognitionGeneration &+= 1
let generation = self.recognitionGeneration
func startRecognition(lifecycleGeneration: Int) async -> Bool {
guard let recognitionAttempt = beginRecognitionAttempt(
lifecycleGeneration: lifecycleGeneration)
else { return false }
let voiceWakeLocale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID }
let selectedInputUID = await MainActor.run { AppStateStore.shared.voiceWakeMicID }
let supportedLocaleIDs = Set(SFSpeechRecognizer.supportedLocales().map(\.identifier))
let localeID = TalkConfigParsing.resolvedSpeechRecognitionLocaleID(
preferredLocaleIDs: [
@@ -242,71 +315,97 @@ actor TalkModeRuntime {
Locale.autoupdatingCurrent.identifier,
],
supportedLocaleIDs: supportedLocaleIDs)
self.recognizer = localeID
let recognizer = localeID
.map { SFSpeechRecognizer(locale: Locale(identifier: $0)) }
?? SFSpeechRecognizer()
guard let recognizer, recognizer.isAvailable else {
self.logger.error("talk recognizer unavailable")
return
return false
}
self.logger.debug("talk recognizer locale=\(recognizer.locale.identifier, privacy: .public)")
let request = SFSpeechAudioBufferRecognitionRequest()
Self.configureRecognitionRequest(request)
self.recognitionRequest = request
let selectedInputUID = await MainActor.run { AppStateStore.shared.voiceWakeMicID }
let selection = AudioInputDeviceObserver.resolveSelection(selectedInputUID)
// Preparation above crosses MainActor. Hardware can become owned/running only
// after this lifecycle and recognition attempt are revalidated together.
guard self.canCommitRecognitionStart(
lifecycleGeneration: lifecycleGeneration,
recognitionAttempt: recognitionAttempt) else { return false }
// AVAudioEngine materializes inputNode from the system default before CurrentDevice can bind.
// Without a usable default, accessing inputNode can SIGABRT even when another UID is alive.
guard selection.resolvedUID != nil, AudioInputDeviceObserver.hasUsableDefaultInputDevice() else {
self.audioEngine = nil
self.activeInputResolution = nil
self.logger.error("talk mode: no usable audio input device")
return
return false
}
do {
try self.configureAudioEngine(
selection: selection,
request: request,
enableVoiceProcessing: true)
} catch {
self.logger.warning(
"talk processed input setup failed; retrying without voice processing: " +
"\(error.localizedDescription, privacy: .public)")
self.discardAudioEngine()
do {
try self.configureAudioEngine(
let started = TalkRecognitionCaptureLifecycle.start(
isCurrent: {
self.canCommitRecognitionStart(
lifecycleGeneration: lifecycleGeneration,
recognitionAttempt: recognitionAttempt)
},
prepare: { enableVoiceProcessing in
try self.prepareStartedRecognitionCapture(
selection: selection,
request: request,
enableVoiceProcessing: false)
} catch {
self.discardAudioEngine()
self.logger.error(
"talk audio engine start failed: \(error.localizedDescription, privacy: .public)")
return
}
}
enableVoiceProcessing: enableVoiceProcessing)
},
discard: { $0.discard() },
publish: { preparedCapture in
self.recognizer = recognizer
self.recognitionRequest = preparedCapture.request
self.audioEngine = preparedCapture.engine
self.activeInputResolution = preparedCapture.activeInputResolution
self.recognitionTask = recognizer.recognitionTask(
with: preparedCapture.request,
resultHandler: { [weak self, recognitionAttempt] result, error in
guard let self else { return }
let segments = result?.bestTranscription.segments ?? []
let transcript = result?.bestTranscription.formattedString
let update = RecognitionUpdate(
transcript: transcript,
hasConfidence: segments.contains { $0.confidence > 0.6 },
isFinal: result?.isFinal ?? false,
errorDescription: error?.localizedDescription,
generation: recognitionAttempt)
Task { await self.handleRecognition(update) }
})
},
onFailure: { enableVoiceProcessing, error in
if enableVoiceProcessing {
self.logger.warning(
"talk processed input start failed; retrying without voice processing: " +
"\(error.localizedDescription, privacy: .public)")
} else {
self.logger.error(
"talk audio engine start failed: \(error.localizedDescription, privacy: .public)")
}
})
guard started else { return false }
self.startRMSTicker(meter: self.rmsMeter)
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self, generation] result, error in
guard let self else { return }
let segments = result?.bestTranscription.segments ?? []
let transcript = result?.bestTranscription.formattedString
let update = RecognitionUpdate(
transcript: transcript,
hasConfidence: segments.contains { $0.confidence > 0.6 },
isFinal: result?.isFinal ?? false,
errorDescription: error?.localizedDescription,
generation: generation)
Task { await self.handleRecognition(update) }
}
return true
}
private func stopRecognition() async {
func beginRecognitionAttempt(lifecycleGeneration: Int) -> Int? {
guard self.isCurrent(lifecycleGeneration), !self.isPaused else { return nil }
self.recognitionGeneration &+= 1
let recognitionAttempt = self.recognitionGeneration
self.discardRecognitionResources()
return recognitionAttempt
}
func canCommitRecognitionStart(lifecycleGeneration: Int, recognitionAttempt: Int) -> Bool {
self.isCurrent(lifecycleGeneration) && !self.isPaused && self.recognitionGeneration == recognitionAttempt
}
private func stopRecognition() {
self.recognitionGeneration &+= 1
self.discardRecognitionResources()
}
func discardRecognitionResources() {
#if DEBUG
self.recognitionCleanupProbe?()
#endif
self.recognitionTask?.cancel()
self.recognitionTask = nil
self.recognitionRequest?.endAudio()
@@ -368,7 +467,7 @@ actor TalkModeRuntime {
// MARK: - Silence handling
private func startSilenceMonitor() {
func startSilenceMonitor() {
self.silenceTask?.cancel()
self.silenceTask = Task { [weak self] in
await self?.silenceLoop()
@@ -417,7 +516,7 @@ actor TalkModeRuntime {
if sendChime != .none {
await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "talk.send") }
}
await self.stopRecognition()
self.stopRecognition()
await sendAndSpeak(text)
}
@@ -451,49 +550,55 @@ actor TalkModeRuntime {
return selection
}
private func configureAudioEngine(
private func prepareStartedRecognitionCapture(
selection: AudioInputDeviceResolution,
request: SFSpeechAudioBufferRecognitionRequest,
enableVoiceProcessing: Bool) throws
enableVoiceProcessing: Bool)
throws -> PreparedRecognitionCapture
{
let request = SFSpeechAudioBufferRecognitionRequest()
TalkRecognitionCaptureLifecycle.configure(request)
let audioEngine = AVAudioEngine()
self.audioEngine = audioEngine
let input = audioEngine.inputNode
if enableVoiceProcessing {
do {
var tapInstalled = false
do {
if enableVoiceProcessing {
try input.setVoiceProcessingEnabled(true)
} catch {
// Aggregate devices can reject voice processing; capture still works without it.
self.logger.warning(
"talk voice processing unavailable: \(error.localizedDescription, privacy: .public)")
}
}
let activeResolution = self.bindSelectedInputIfNeeded(selection, to: input)
guard activeResolution.resolvedUID != nil else {
throw TalkAudioInputError.unavailable
}
let activeResolution = self.bindSelectedInputIfNeeded(selection, to: input)
guard activeResolution.resolvedUID != nil else {
throw TalkAudioInputError.unavailable
}
let format = input.outputFormat(forBus: 0)
guard format.channelCount > 0, format.sampleRate > 0 else {
throw TalkAudioInputError.invalidFormat
let format = input.outputFormat(forBus: 0)
guard format.channelCount > 0, format.sampleRate > 0 else {
throw TalkAudioInputError.invalidFormat
}
input.removeTap(onBus: 0)
let meter = self.rmsMeter
input.installTap(
onBus: 0,
bufferSize: 2048,
format: format)
{ [weak request, meter] buffer, _ in
request?.append(SpeechAudioBufferNormalizer.speechCompatibleBuffer(from: buffer))
meter.set(TalkAudioLevel.rms(buffer: buffer))
}
tapInstalled = true
audioEngine.prepare()
try audioEngine.start()
return PreparedRecognitionCapture(
request: request,
engine: audioEngine,
activeInputResolution: activeResolution)
} catch {
request.endAudio()
if tapInstalled {
input.removeTap(onBus: 0)
}
audioEngine.stop()
throw error
}
input.removeTap(onBus: 0)
let meter = self.rmsMeter
input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request, meter] buffer, _ in
request?.append(SpeechAudioBufferNormalizer.speechCompatibleBuffer(from: buffer))
meter.set(TalkAudioLevel.rms(buffer: buffer))
}
audioEngine.prepare()
try audioEngine.start()
self.activeInputResolution = activeResolution
}
private func discardAudioEngine() {
self.audioEngine?.inputNode.removeTap(onBus: 0)
self.audioEngine?.stop()
self.audioEngine = nil
self.activeInputResolution = nil
}
private func defaultFallback(for selection: AudioInputDeviceResolution) -> AudioInputDeviceResolution {
@@ -504,18 +609,6 @@ actor TalkModeRuntime {
}
}
private enum TalkAudioInputError: LocalizedError {
case unavailable
case invalidFormat
var errorDescription: String? {
switch self {
case .unavailable: "Selected input and system default are unavailable"
case .invalidFormat: "Selected audio input has no usable format"
}
}
}
// MARK: - Gateway + TTS
extension TalkModeRuntime {
@@ -583,8 +676,9 @@ extension TalkModeRuntime {
guard let assistantText
else {
self.logger.warning("talk assistant text missing after timeout")
guard await self.startRecognition(lifecycleGeneration: gen),
self.isCurrent(gen), !self.isPaused else { return }
await self.startListening()
await self.startRecognition()
return
}
guard self.isCurrent(gen) else { return }
@@ -611,8 +705,10 @@ extension TalkModeRuntime {
}
return
}
let lifecycleGeneration = self.lifecycleGeneration
guard await self.startRecognition(lifecycleGeneration: lifecycleGeneration),
self.isCurrent(lifecycleGeneration), !self.isPaused else { return }
await self.startListening()
await self.startRecognition()
}
private func buildPrompt(transcript: String) -> String {
@@ -1113,8 +1209,8 @@ extension TalkModeRuntime {
}
private func prepareForPlayback(generation: Int) async -> Bool {
await self.startRecognition()
return self.isCurrent(generation)
guard await self.startRecognition(lifecycleGeneration: generation) else { return false }
return self.isCurrent(generation) && !self.isPaused
}
private func resolveVoiceId(preferred: String?, apiKey: String) async -> String? {
@@ -1170,13 +1266,60 @@ extension TalkModeRuntime {
return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
}
func stopSpeaking(reason: TalkStopReason) async {
func stopSpeaking(
reason: TalkStopReason,
reconfigurationGeneration expectedReconfigurationGeneration: UInt64? = nil,
lifecycleGeneration expectedLifecycleGeneration: Int? = nil) async
{
guard self.ownsReconfiguration(
expectedReconfigurationGeneration,
lifecycleGeneration: expectedLifecycleGeneration)
else { return }
if let realtimeSession {
let relayReason = switch reason {
case .userTap: "user"
case .speech: "barge-in"
case .manual: "shutdown"
}
let cancelled = await MainActor.run {
realtimeSession.cancelOutput(reason: relayReason)
}
guard self.ownsReconfiguration(
expectedReconfigurationGeneration,
lifecycleGeneration: expectedLifecycleGeneration)
else { return }
if cancelled, reason != .manual, !self.isPaused {
self.phase = .listening
await MainActor.run { TalkModeController.shared.updatePhase(.listening) }
}
return
}
let usePCM = self.lastPlaybackWasPCM
let remoteInterruptedAt = usePCM ? await stopPCM() : await stopMP3()
guard self.ownsReconfiguration(
expectedReconfigurationGeneration,
lifecycleGeneration: expectedLifecycleGeneration)
else { return }
_ = usePCM ? await stopMP3() : await stopPCM()
guard self.ownsReconfiguration(
expectedReconfigurationGeneration,
lifecycleGeneration: expectedLifecycleGeneration)
else { return }
let localInterruptedAt = await stopTalkAudio()
guard self.ownsReconfiguration(
expectedReconfigurationGeneration,
lifecycleGeneration: expectedLifecycleGeneration)
else { return }
await TalkSystemSpeechSynthesizer.shared.stop()
guard self.ownsReconfiguration(
expectedReconfigurationGeneration,
lifecycleGeneration: expectedLifecycleGeneration)
else { return }
await stopMLXVoice()
guard self.ownsReconfiguration(
expectedReconfigurationGeneration,
lifecycleGeneration: expectedLifecycleGeneration)
else { return }
guard self.phase == .speaking else { return }
let interruptedAt = remoteInterruptedAt ?? localInterruptedAt
if reason == .speech, let interruptedAt {
@@ -1308,10 +1451,72 @@ extension TalkModeRuntime {
await TalkMLXSpeechSynthesizer.shared.cancelCurrent()
}
func parseTalkConfig(_ snap: ConfigSnapshot) -> TalkModeGatewayConfigState {
let env = ProcessInfo.processInfo.environment
let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
let sagVoice = env["SAG_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
let envApiKey = env["ELEVENLABS_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines)
let parsed = TalkModeGatewayConfigParser.parse(
snapshot: snap,
defaultProvider: Self.defaultTalkProvider,
defaultModelIdFallback: Self.defaultModelIdFallback,
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs,
envVoice: envVoice,
sagVoice: sagVoice,
envApiKey: envApiKey)
if parsed.missingResolvedPayload {
self.ttsLogger.info("talk config ignored: normalized payload missing talk.resolved")
}
if parsed.activeProvider == Self.defaultTalkProvider {
self.ttsLogger.info("talk config provider from talk.resolved")
} else if parsed.activeProvider == Self.mlxTalkProvider ||
parsed.activeProvider == Self.systemTalkProvider
{
self.ttsLogger.info(
"talk provider \(parsed.activeProvider, privacy: .public) active")
} else {
self.ttsLogger
.info(
"""
talk provider \(parsed.activeProvider, privacy: .public) uses gateway talk.speak \
with system voice fallback
""")
}
return parsed
}
private func fetchTalkConfig() async -> TalkModeGatewayConfigState {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .talkConfig,
params: ["includeSecrets": AnyCodable(true)],
timeoutMs: 8000)
return self.parseTalkConfig(snap)
} catch {
return self.fallbackTalkConfig()
}
}
// MARK: - Config
private func reloadConfig() async {
func reloadConfig() async {
let cfg = await fetchTalkConfig()
await self.applyTalkConfig(cfg)
self.macOSRealtimeRelayOptIn = await MainActor.run {
AppStateStore.shared.talkRealtimeRelayEnabled
}
}
func applyTalkConfig(_ cfg: TalkModeGatewayConfigState) async {
let locale = await MainActor.run {
AppStateStore.shared.seamColorHex = cfg.seamColorHex
return AppStateStore.shared.voiceWakeLocaleID
}
self.commitTalkConfig(cfg, locale: locale)
}
func commitTalkConfig(_ cfg: TalkModeGatewayConfigState, locale: String) {
self.defaultVoiceId = cfg.voiceId
self.voiceAliases = cfg.voiceAliases
if !self.voiceOverrideActive {
@@ -1324,8 +1529,14 @@ extension TalkModeRuntime {
self.defaultOutputFormat = cfg.outputFormat
self.interruptOnSpeech = cfg.interruptOnSpeech
self.activeTalkProvider = cfg.activeProvider
self.realtimeProvider = cfg.realtimeProvider
self.realtimeModelId = cfg.realtimeModelId
self.realtimeSpeakerVoice = cfg.realtimeSpeakerVoice
self.realtimeMode = cfg.realtimeMode
self.realtimeTransport = cfg.realtimeTransport
self.realtimeBrain = cfg.realtimeBrain
self.hasGatewayRealtimeRelayTuple = cfg.hasGatewayRealtimeRelayTuple
let configuredSilenceMs = cfg.silenceTimeoutMs
let locale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID }
let isCJKLocale = locale.hasPrefix("ko") || locale.hasPrefix("ja") || locale.hasPrefix("zh")
let effectiveSilenceMs = isCJKLocale ? max(configuredSilenceMs, 2000) : configuredSilenceMs
if isCJKLocale, configuredSilenceMs < 2000 {
@@ -1351,7 +1562,11 @@ extension TalkModeRuntime {
"apiKey=\(hasApiKey, privacy: .public) " +
"interrupt=\(cfg.interruptOnSpeech, privacy: .public) " +
"silenceTimeoutMs=\(cfg.silenceTimeoutMs, privacy: .public) " +
"speechLocale=\(cfg.speechLocaleID ?? "device", privacy: .public)")
"speechLocale=\(cfg.speechLocaleID ?? "device", privacy: .public) " +
"realtimeMode=\(cfg.realtimeMode ?? "off", privacy: .public) " +
"realtimeTransport=\(cfg.realtimeTransport ?? "default", privacy: .public) " +
"realtimeBrain=\(cfg.realtimeBrain ?? "default", privacy: .public) " +
"macOSRealtimeOptIn=\(self.macOSRealtimeRelayOptIn, privacy: .public)")
}
static func selectTalkProviderConfig(
@@ -1364,57 +1579,6 @@ extension TalkModeRuntime {
TalkConfigParsing.resolvedSilenceTimeoutMs(talk, fallback: self.defaultSilenceTimeoutMs)
}
private func fetchTalkConfig() async -> TalkModeGatewayConfigState {
let env = ProcessInfo.processInfo.environment
let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
let sagVoice = env["SAG_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
let envApiKey = env["ELEVENLABS_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines)
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .talkConfig,
params: ["includeSecrets": AnyCodable(true)],
timeoutMs: 8000)
let parsed = TalkModeGatewayConfigParser.parse(
snapshot: snap,
defaultProvider: Self.defaultTalkProvider,
defaultModelIdFallback: Self.defaultModelIdFallback,
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs,
envVoice: envVoice,
sagVoice: sagVoice,
envApiKey: envApiKey)
if parsed.missingResolvedPayload {
self.ttsLogger.info("talk config ignored: normalized payload missing talk.resolved")
}
await MainActor.run {
AppStateStore.shared.seamColorHex = parsed.seamColorHex
}
if parsed.activeProvider == Self.defaultTalkProvider {
self.ttsLogger.info("talk config provider from talk.resolved")
} else if parsed.activeProvider == Self.mlxTalkProvider ||
parsed.activeProvider == Self.systemTalkProvider
{
self.ttsLogger.info(
"talk provider \(parsed.activeProvider, privacy: .public) active")
} else {
self.ttsLogger
.info(
"""
talk provider \(parsed.activeProvider, privacy: .public) uses gateway talk.speak \
with system voice fallback
""")
}
return parsed
} catch {
return TalkModeGatewayConfigParser.fallback(
defaultModelIdFallback: Self.defaultModelIdFallback,
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs,
envVoice: envVoice,
sagVoice: sagVoice,
envApiKey: envApiKey)
}
}
// MARK: - Audio level handling
private func noteAudioLevel(rms: Double) async {
@@ -0,0 +1,57 @@
@preconcurrency import AVFoundation
import Foundation
import Speech
struct PreparedRecognitionCapture {
let request: SFSpeechAudioBufferRecognitionRequest
let engine: AVAudioEngine
let activeInputResolution: AudioInputDeviceResolution
func discard() {
self.request.endAudio()
self.engine.inputNode.removeTap(onBus: 0)
self.engine.stop()
}
}
enum TalkAudioInputError: LocalizedError {
case unavailable
case invalidFormat
var errorDescription: String? {
switch self {
case .unavailable: "Selected input and system default are unavailable"
case .invalidFormat: "Selected audio input has no usable format"
}
}
}
enum TalkRecognitionCaptureLifecycle {
static func configure(_ request: SFSpeechAudioBufferRecognitionRequest) {
SpeechRecognitionRequestPolicy.configureInteractiveTranscription(request)
}
static func start<Capture>(
isCurrent: () -> Bool,
prepare: (_ enableVoiceProcessing: Bool) throws -> Capture,
discard: (Capture) -> Void,
publish: (Capture) -> Void,
onFailure: (_ enableVoiceProcessing: Bool, _ error: Error) -> Void) -> Bool
{
for enableVoiceProcessing in [true, false] {
guard isCurrent() else { return false }
do {
let capture = try prepare(enableVoiceProcessing)
guard isCurrent() else {
discard(capture)
return false
}
publish(capture)
return true
} catch {
onFailure(enableVoiceProcessing, error)
}
}
return false
}
}
@@ -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 {
@@ -53,4 +53,116 @@ struct TalkModeGatewayConfigTests {
#expect(parsed.referenceAudioPath == "/tmp/reference.wav")
#expect(parsed.referenceText == "reference transcript")
}
@Test func `realtime config uses top level overrides and normalizes control values`() {
let snapshot = Self.snapshot(talk: [
"realtime": [
"provider": " OpenAI ",
"providers": [
"openai": [
"model": "provider-model",
"speakerVoice": "alloy",
],
],
"model": " gpt-live-1-codex ",
"speakerVoice": " cedar ",
"mode": " Realtime ",
"transport": " Gateway-Relay ",
"brain": " Agent-Consult ",
],
])
let parsed = Self.parse(snapshot)
#expect(parsed.realtimeProvider == "OpenAI")
#expect(parsed.realtimeModelId == "gpt-live-1-codex")
#expect(parsed.realtimeSpeakerVoice == "cedar")
#expect(parsed.realtimeMode == "realtime")
#expect(parsed.realtimeTransport == "gateway-relay")
#expect(parsed.realtimeBrain == "agent-consult")
#expect(parsed.hasGatewayRealtimeRelayTuple)
}
@Test func `realtime config infers its sole provider and reads provider defaults`() {
let snapshot = Self.snapshot(talk: [
"realtime": [
"providers": [
"openai": [
"model": "gpt-realtime-2.1",
"voice": "marin",
],
],
"mode": "realtime",
],
])
let parsed = Self.parse(snapshot)
#expect(parsed.realtimeProvider == "openai")
#expect(parsed.realtimeModelId == "gpt-realtime-2.1")
#expect(parsed.realtimeSpeakerVoice == "marin")
#expect(parsed.realtimeMode == "realtime")
#expect(parsed.realtimeTransport == nil)
#expect(parsed.realtimeBrain == nil)
#expect(!parsed.hasGatewayRealtimeRelayTuple)
}
@Test func `realtime provider config lookup is case insensitive`() {
let snapshot = Self.snapshot(talk: [
"realtime": [
"provider": "OPENAI",
"providers": [
"openai": [
"model": "gpt-live-1-codex",
"speakerVoice": "cedar",
],
],
],
])
let parsed = Self.parse(snapshot)
#expect(parsed.realtimeProvider == "OPENAI")
#expect(parsed.realtimeModelId == "gpt-live-1-codex")
#expect(parsed.realtimeSpeakerVoice == "cedar")
}
@Test func `fallback has no realtime selection`() {
let parsed = TalkModeGatewayConfigParser.fallback(
defaultModelIdFallback: "eleven_v3",
defaultSilenceTimeoutMs: TalkDefaults.silenceTimeoutMs,
envVoice: nil,
sagVoice: nil,
envApiKey: nil)
#expect(parsed.realtimeProvider == nil)
#expect(parsed.realtimeModelId == nil)
#expect(parsed.realtimeSpeakerVoice == nil)
#expect(parsed.realtimeMode == nil)
#expect(parsed.realtimeTransport == nil)
#expect(parsed.realtimeBrain == nil)
}
private static func snapshot(talk: [String: Any]) -> ConfigSnapshot {
ConfigSnapshot(
path: nil,
exists: true,
raw: nil,
hash: nil,
parsed: nil,
valid: true,
config: ["talk": AnyCodable(talk)],
issues: nil)
}
private static func parse(_ snapshot: ConfigSnapshot) -> TalkModeGatewayConfigState {
TalkModeGatewayConfigParser.parse(
snapshot: snapshot,
defaultProvider: "elevenlabs",
defaultModelIdFallback: "eleven_v3",
defaultSilenceTimeoutMs: TalkDefaults.silenceTimeoutMs,
envVoice: nil,
sagVoice: nil,
envApiKey: nil)
}
}
File diff suppressed because it is too large Load Diff
+69
View File
@@ -58,6 +58,75 @@ stopping Talk releases the camera and microphone tracks.
- Replies are written to WebChat (same as typing).
- **Interrupt on speech** (default on): if the user talks while the assistant is speaking, playback stops and the interruption timestamp is noted for the next prompt.
## Realtime Talk over the Gateway relay (macOS)
macOS defaults to the native path above: Apple Speech recognition, Gateway chat, and `talk.speak`
playback. It switches to a streamed realtime session only when `talk.realtime` selects all three
of these together:
| Key | Required value |
| ----------- | --------------- |
| `mode` | `realtime` |
| `transport` | `gateway-relay` |
| `brain` | `agent-consult` |
Any other combination — including a partially set one — keeps the native path.
```json5
{
talk: {
realtime: {
provider: "openai",
providers: {
openai: {
model: "gpt-realtime-2.1",
speakerVoice: "cedar",
},
},
mode: "realtime",
transport: "gateway-relay",
brain: "agent-consult",
},
},
}
```
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. Keep `transport: "webrtc"` for browser or iOS client-owned sessions; macOS uses
the relay only when the config explicitly selects `gateway-relay`.
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 and Voice Wake controls are unavailable.
### When realtime cannot start
Talk never silently sits idle. If the relay fails to start — no Gateway route, rejected
credentials, or an unsupported model — the failure is logged, the overlay shows the reason, and
Talk falls back to the native speech path for that session.
Once a session is running, a dropped relay reconnects on a bounded retry schedule (roughly 0.5 s
then 2 s). If those attempts are exhausted, the overlay reports
`Realtime disconnected repeatedly — using native speech` and the next start bypasses realtime.
Losing the microphone mid-session closes the relay and takes the same route.
Relay output cancellation is turn-scoped. Clients copy the current `turnId` from the
`talk.event` audio envelope. Matching ids return `applied`, stale ids return `stale`, and
sessions without an active turn return `idle`. Older clients that omit `turnId` still cancel
the current turn:
```json
{
"method": "talk.session.cancelOutput",
"params": {
"sessionId": "relay-session-id",
"turnId": "turn-7",
"reason": "barge-in"
}
}
```
## Voice directives in replies
The assistant can prefix a reply with a single JSON line to control voice: