mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 10:25:20 -06:00
feat(talk): integrate realtime relay with macOS Talk
Co-authored-by: Zhilong Zheng <zhengzhilong1115@gmail.com>
This commit is contained in:
Generated
+99
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user