mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:21:35 +00:00
fix(ios): use configured speech language for system voice (#113372)
* fix(ios): honor speech locale for system voice * chore(ios): refresh talk locale i18n inventory * fix(ios): fall through unavailable system voices * chore(ios): refresh talk locale i18n inventory --------- Co-authored-by: Colin Johnson <colin@solvely.net> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -27811,7 +27811,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 614,
|
||||
"line": 623,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Gateway relay ready",
|
||||
"surface": "apple",
|
||||
@@ -27819,7 +27819,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 616,
|
||||
"line": 625,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Realtime Voice, Gateway relay ready",
|
||||
"surface": "apple",
|
||||
@@ -27827,7 +27827,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 618,
|
||||
"line": 627,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Listening starts from this phone",
|
||||
"surface": "apple",
|
||||
@@ -27835,7 +27835,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 864,
|
||||
"line": 873,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Off",
|
||||
"surface": "apple",
|
||||
@@ -27843,7 +27843,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 867,
|
||||
"line": 876,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Not active",
|
||||
"surface": "apple",
|
||||
@@ -27851,7 +27851,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1055,
|
||||
"line": 1064,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Requesting permissions…",
|
||||
"surface": "apple",
|
||||
@@ -27859,7 +27859,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1084,
|
||||
"line": 1093,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Start failed: %@",
|
||||
"surface": "apple",
|
||||
@@ -27867,7 +27867,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1278,
|
||||
"line": 1287,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Microphone permission denied",
|
||||
"surface": "apple",
|
||||
@@ -27875,7 +27875,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1288,
|
||||
"line": 1297,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Speech recognition",
|
||||
"surface": "apple",
|
||||
@@ -27883,7 +27883,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1448,
|
||||
"line": 1457,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Offline",
|
||||
"surface": "apple",
|
||||
@@ -27891,7 +27891,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1647,
|
||||
"line": 1656,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Speech error: %@",
|
||||
"surface": "apple",
|
||||
@@ -27899,7 +27899,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1962,
|
||||
"line": 1971,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Gateway not connected",
|
||||
"surface": "apple",
|
||||
@@ -27907,7 +27907,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2006,
|
||||
"line": 2015,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Aborted",
|
||||
"surface": "apple",
|
||||
@@ -27915,7 +27915,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 2006,
|
||||
"line": 2015,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Chat error",
|
||||
"surface": "apple",
|
||||
@@ -27923,7 +27923,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 2034,
|
||||
"line": 2043,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Talk failed: %@",
|
||||
"surface": "apple",
|
||||
@@ -27931,7 +27931,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 2138,
|
||||
"line": 2147,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "No reply",
|
||||
"surface": "apple",
|
||||
@@ -27939,7 +27939,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 2310,
|
||||
"line": 2319,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Paused",
|
||||
"surface": "apple",
|
||||
@@ -27947,7 +27947,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 2896,
|
||||
"line": 2905,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Generating voice…",
|
||||
"surface": "apple",
|
||||
@@ -27955,7 +27955,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 3034,
|
||||
"line": 3043,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Speak failed: %@",
|
||||
"surface": "apple",
|
||||
@@ -27963,7 +27963,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 3133,
|
||||
"line": 3142,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Speaking (System)…",
|
||||
"surface": "apple",
|
||||
@@ -27971,7 +27971,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4014,
|
||||
"line": 4038,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "iOS System Voice",
|
||||
"surface": "apple",
|
||||
@@ -27979,7 +27979,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4016,
|
||||
"line": 4040,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Realtime Voice",
|
||||
"surface": "apple",
|
||||
@@ -27987,7 +27987,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4020,
|
||||
"line": 4044,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Gateway Default",
|
||||
"surface": "apple",
|
||||
@@ -27995,7 +27995,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4081,
|
||||
"line": 4105,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Listening (Realtime)",
|
||||
"surface": "apple",
|
||||
@@ -28003,7 +28003,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4083,
|
||||
"line": 4107,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Thinking",
|
||||
"surface": "apple",
|
||||
@@ -28011,7 +28011,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4085,
|
||||
"line": 4109,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Thinking…",
|
||||
"surface": "apple",
|
||||
@@ -28019,7 +28019,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4087,
|
||||
"line": 4111,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Asking OpenClaw",
|
||||
"surface": "apple",
|
||||
@@ -28027,7 +28027,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4089,
|
||||
"line": 4113,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Still asking OpenClaw",
|
||||
"surface": "apple",
|
||||
@@ -28035,7 +28035,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4091,
|
||||
"line": 4115,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Updating OpenClaw",
|
||||
"surface": "apple",
|
||||
@@ -28043,7 +28043,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4093,
|
||||
"line": 4117,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Speaking",
|
||||
"surface": "apple",
|
||||
@@ -28051,7 +28051,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4095,
|
||||
"line": 4119,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Speaking…",
|
||||
"surface": "apple",
|
||||
@@ -28059,7 +28059,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4097,
|
||||
"line": 4121,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Connecting",
|
||||
"surface": "apple",
|
||||
@@ -28067,7 +28067,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4099,
|
||||
"line": 4123,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Connecting realtime…",
|
||||
"surface": "apple",
|
||||
@@ -28075,7 +28075,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4101,
|
||||
"line": 4125,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Waiting for realtime…",
|
||||
"surface": "apple",
|
||||
@@ -28083,7 +28083,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4103,
|
||||
"line": 4127,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Ready",
|
||||
"surface": "apple",
|
||||
@@ -28091,7 +28091,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4105,
|
||||
"line": 4129,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Reconnecting",
|
||||
"surface": "apple",
|
||||
@@ -28099,7 +28099,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4107,
|
||||
"line": 4131,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Reconnecting…",
|
||||
"surface": "apple",
|
||||
@@ -28107,7 +28107,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4109,
|
||||
"line": 4133,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Realtime failed before connecting",
|
||||
"surface": "apple",
|
||||
@@ -28115,7 +28115,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4111,
|
||||
"line": 4135,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Realtime disconnected",
|
||||
"surface": "apple",
|
||||
@@ -28123,7 +28123,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4113,
|
||||
"line": 4137,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "OpenClaw unavailable",
|
||||
"surface": "apple",
|
||||
@@ -28131,7 +28131,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4115,
|
||||
"line": 4139,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Confirmation needed",
|
||||
"surface": "apple",
|
||||
@@ -28139,7 +28139,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4155,
|
||||
"line": 4179,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "iOS Speech + TTS",
|
||||
"surface": "apple",
|
||||
@@ -28147,7 +28147,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4157,
|
||||
"line": 4181,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Listening",
|
||||
"surface": "apple",
|
||||
@@ -28155,7 +28155,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4161,
|
||||
"line": 4185,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "iOS Speech fallback",
|
||||
"surface": "apple",
|
||||
@@ -28163,7 +28163,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4422,
|
||||
"line": 4446,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Gateway Relay",
|
||||
"surface": "apple",
|
||||
@@ -28171,7 +28171,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4424,
|
||||
"line": 4448,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Native WebRTC",
|
||||
"surface": "apple",
|
||||
@@ -28179,7 +28179,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4425,
|
||||
"line": 4449,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Native",
|
||||
"surface": "apple",
|
||||
@@ -28187,7 +28187,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4486,
|
||||
"line": 4510,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Gateway permission required",
|
||||
"surface": "apple",
|
||||
@@ -28195,7 +28195,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4508,
|
||||
"line": 4532,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Not loaded",
|
||||
"surface": "apple",
|
||||
@@ -28203,7 +28203,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4526,
|
||||
"line": 4550,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Approval requested",
|
||||
"surface": "apple",
|
||||
@@ -28211,7 +28211,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 5043,
|
||||
"line": 5080,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Realtime unavailable",
|
||||
"surface": "apple",
|
||||
@@ -28219,7 +28219,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 5065,
|
||||
"line": 5102,
|
||||
"path": "apps/ios/Sources/Voice/TalkModeManager.swift",
|
||||
"source": "Listening (PTT)",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -71,6 +71,13 @@ private struct ChatCompletionResult {
|
||||
var assistantText: String?
|
||||
}
|
||||
|
||||
private struct TalkSpeechLanguageSelection: Equatable {
|
||||
/// Provider synthesis accepts a two-letter language, while the system
|
||||
/// voice accepts a BCP 47 locale. Keep both forms so fallback stays correct.
|
||||
let provider: String?
|
||||
let systemVoice: String?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TranscriptStreamingOwner {
|
||||
var task: Task<Void, Never>?
|
||||
@@ -324,7 +331,9 @@ final class TalkModeManager: NSObject {
|
||||
private var incrementalSpeechTasksByGeneration: [Int: Task<Void, Never>] = [:]
|
||||
private var incrementalSpeechActive = false
|
||||
private var incrementalSpeechUsed = false
|
||||
private var incrementalSpeechLanguage: String?
|
||||
private var incrementalSpeechLanguages = TalkSpeechLanguageSelection(
|
||||
provider: nil,
|
||||
systemVoice: nil)
|
||||
private var incrementalSpeechBuffer = IncrementalSpeechBuffer()
|
||||
private var incrementalSpeechContext: IncrementalSpeechContext?
|
||||
private var incrementalSpeechDirective: TalkDirective?
|
||||
@@ -2905,7 +2914,7 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
let language = ElevenLabsTTSClient.validatedLanguage(directive?.language)
|
||||
let languages = self.resolvedSpeechLanguages(directiveLanguage: directive?.language)
|
||||
if self.runtimeRoute.usesGatewayTalkSpeak {
|
||||
do {
|
||||
try await self.playGatewayTalkSpeak(
|
||||
@@ -2922,7 +2931,7 @@ final class TalkModeManager: NSObject {
|
||||
self.logger.error("gateway TTS failed: \(errorMessage, privacy: .public); falling back to system voice")
|
||||
GatewayDiagnostics.log("talk tts: provider=system (gateway error) msg=\(error.localizedDescription)")
|
||||
do {
|
||||
try await self.playSystemVoice(text: cleaned, language: language)
|
||||
try await self.playSystemVoice(text: cleaned, language: languages.systemVoice)
|
||||
} catch {
|
||||
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
|
||||
let status = String(
|
||||
@@ -2987,7 +2996,7 @@ final class TalkModeManager: NSObject {
|
||||
directive: directive,
|
||||
modelId: modelId,
|
||||
outputFormat: outputFormat,
|
||||
language: language)
|
||||
language: languages.provider)
|
||||
|
||||
let client = ElevenLabsTTSClient(apiKey: apiKey)
|
||||
let rawStream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
@@ -3006,7 +3015,7 @@ final class TalkModeManager: NSObject {
|
||||
directive: directive,
|
||||
modelId: modelId,
|
||||
outputFormat: mp3Format,
|
||||
language: language))
|
||||
language: languages.provider))
|
||||
}
|
||||
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
|
||||
let duration = Date().timeIntervalSince(started)
|
||||
@@ -3019,7 +3028,7 @@ final class TalkModeManager: NSObject {
|
||||
} else {
|
||||
self.logger.warning("tts unavailable; falling back to system voice (missing key or voiceId)")
|
||||
GatewayDiagnostics.log("talk tts: provider=system (missing key or voiceId)")
|
||||
try await self.playSystemVoice(text: cleaned, language: language)
|
||||
try await self.playSystemVoice(text: cleaned, language: languages.systemVoice)
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
|
||||
@@ -3027,7 +3036,7 @@ final class TalkModeManager: NSObject {
|
||||
"tts failed: \(error.localizedDescription, privacy: .public); falling back to system voice")
|
||||
GatewayDiagnostics.log("talk tts: provider=system (error) msg=\(error.localizedDescription)")
|
||||
do {
|
||||
try await self.playSystemVoice(text: cleaned, language: language)
|
||||
try await self.playSystemVoice(text: cleaned, language: languages.systemVoice)
|
||||
} catch {
|
||||
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
|
||||
let status = String(
|
||||
@@ -3134,6 +3143,21 @@ final class TalkModeManager: NSObject {
|
||||
try await TalkSystemSpeechSynthesizer.shared.speak(text: text, language: language)
|
||||
}
|
||||
|
||||
private func resolvedSpeechLanguages(
|
||||
directiveLanguage: String?,
|
||||
localSelection: String? = UserDefaults.standard.string(forKey: TalkSpeechLocale.storageKey),
|
||||
isSystemVoiceAvailable: (String) -> Bool = TalkSpeechLocale.isSystemVoiceAvailable)
|
||||
-> TalkSpeechLanguageSelection
|
||||
{
|
||||
TalkSpeechLanguageSelection(
|
||||
provider: ElevenLabsTTSClient.validatedLanguage(directiveLanguage),
|
||||
systemVoice: TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: directiveLanguage,
|
||||
localSelection: localSelection,
|
||||
gatewaySelection: self.gatewaySpeechLocaleID,
|
||||
isVoiceAvailable: isSystemVoiceAvailable))
|
||||
}
|
||||
|
||||
private func resolvedElevenLabsAPIKey() -> String? {
|
||||
let configuredKey = self.apiKey?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -3274,7 +3298,7 @@ final class TalkModeManager: NSObject {
|
||||
self.cancelIncrementalPrefetch()
|
||||
self.incrementalSpeechActive = true
|
||||
self.incrementalSpeechUsed = false
|
||||
self.incrementalSpeechLanguage = nil
|
||||
self.incrementalSpeechLanguages = self.resolvedSpeechLanguages(directiveLanguage: nil)
|
||||
self.incrementalSpeechBuffer = IncrementalSpeechBuffer()
|
||||
self.incrementalSpeechContext = nil
|
||||
self.incrementalSpeechDirective = nil
|
||||
@@ -3504,7 +3528,7 @@ final class TalkModeManager: NSObject {
|
||||
let parsed = TalkDirectiveParser.parse(text)
|
||||
self.applyDirective(parsed.directive)
|
||||
if let lang = parsed.directive?.language {
|
||||
self.incrementalSpeechLanguage = ElevenLabsTTSClient.validatedLanguage(lang)
|
||||
self.incrementalSpeechLanguages = self.resolvedSpeechLanguages(directiveLanguage: lang)
|
||||
}
|
||||
guard await self.updateIncrementalContextIfNeeded(speechGeneration: speechGeneration) else { return false }
|
||||
guard self.incrementalSpeechActive,
|
||||
@@ -3553,7 +3577,7 @@ final class TalkModeManager: NSObject {
|
||||
guard let text = OpenClawChatEventText.assistantText(from: chatEvent) else { continue }
|
||||
let segments = self.incrementalSpeechBuffer.ingest(text: text, isFinal: false)
|
||||
if let lang = incrementalSpeechBuffer.directive?.language {
|
||||
self.incrementalSpeechLanguage = ElevenLabsTTSClient.validatedLanguage(lang)
|
||||
self.incrementalSpeechLanguages = self.resolvedSpeechLanguages(directiveLanguage: lang)
|
||||
}
|
||||
guard await self.updateIncrementalContextIfNeeded(speechGeneration: speechGeneration) else { return }
|
||||
guard self.isCurrentTranscriptProcessing(generation),
|
||||
@@ -3569,13 +3593,13 @@ final class TalkModeManager: NSObject {
|
||||
guard self.isCurrentSpeechGeneration(speechGeneration) else { return false }
|
||||
let directive = self.incrementalSpeechBuffer.directive
|
||||
if let existing = incrementalSpeechContext, directive == incrementalSpeechDirective {
|
||||
if existing.language != self.incrementalSpeechLanguage {
|
||||
if existing.language != self.incrementalSpeechLanguages.provider {
|
||||
self.incrementalSpeechContext = IncrementalSpeechContext(
|
||||
apiKey: existing.apiKey,
|
||||
voiceId: existing.voiceId,
|
||||
modelId: existing.modelId,
|
||||
outputFormat: existing.outputFormat,
|
||||
language: self.incrementalSpeechLanguage,
|
||||
language: self.incrementalSpeechLanguages.provider,
|
||||
directive: existing.directive,
|
||||
canUseElevenLabs: existing.canUseElevenLabs)
|
||||
}
|
||||
@@ -3634,7 +3658,7 @@ final class TalkModeManager: NSObject {
|
||||
voiceId: voiceId,
|
||||
modelId: modelId,
|
||||
outputFormat: outputFormat,
|
||||
language: self.incrementalSpeechLanguage,
|
||||
language: self.incrementalSpeechLanguages.provider,
|
||||
directive: directive,
|
||||
canUseElevenLabs: canUseElevenLabs)
|
||||
}
|
||||
@@ -3723,7 +3747,7 @@ final class TalkModeManager: NSObject {
|
||||
guard let resolvedContext = incrementalSpeechContext else {
|
||||
try? await TalkSystemSpeechSynthesizer.shared.speak(
|
||||
text: text,
|
||||
language: self.incrementalSpeechLanguage)
|
||||
language: self.incrementalSpeechLanguages.systemVoice)
|
||||
return
|
||||
}
|
||||
context = resolvedContext
|
||||
@@ -3733,7 +3757,7 @@ final class TalkModeManager: NSObject {
|
||||
guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else {
|
||||
try? await TalkSystemSpeechSynthesizer.shared.speak(
|
||||
text: text,
|
||||
language: self.incrementalSpeechLanguage)
|
||||
language: self.incrementalSpeechLanguages.systemVoice)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4915,6 +4939,19 @@ extension TalkModeManager {
|
||||
await self.playAssistant(text: text)
|
||||
}
|
||||
|
||||
func _test_resolvedSpeechLanguages(
|
||||
directiveLanguage: String?,
|
||||
localSelection: String?,
|
||||
isSystemVoiceAvailable: (String) -> Bool = { _ in true })
|
||||
-> (provider: String?, systemVoice: String?)
|
||||
{
|
||||
let selection = self.resolvedSpeechLanguages(
|
||||
directiveLanguage: directiveLanguage,
|
||||
localSelection: localSelection,
|
||||
isSystemVoiceAvailable: isSystemVoiceAvailable)
|
||||
return (selection.provider, selection.systemVoice)
|
||||
}
|
||||
|
||||
func _test_stopSpeaking(storeInterruption: Bool = true) {
|
||||
self.stopSpeaking(storeInterruption: storeInterruption)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Speech
|
||||
@@ -45,6 +46,23 @@ enum TalkSpeechLocale {
|
||||
supportedLocaleIDs: supportedLocaleIDs)
|
||||
}
|
||||
|
||||
static func resolvedSynthesisLocaleID(
|
||||
directiveLanguage: String?,
|
||||
localSelection: String?,
|
||||
gatewaySelection: String?,
|
||||
isVoiceAvailable: (String) -> Bool = TalkSpeechLocale.isSystemVoiceAvailable) -> String?
|
||||
{
|
||||
// A missing higher-priority voice must not mask a later configured voice.
|
||||
// Return nil only after every candidate fails so synthesis uses the device default.
|
||||
[directiveLanguage, localSelection, gatewaySelection]
|
||||
.compactMap { TalkConfigParsing.normalizedExplicitSpeechLocaleID($0) }
|
||||
.first(where: isVoiceAvailable)
|
||||
}
|
||||
|
||||
static func isSystemVoiceAvailable(_ localeID: String) -> Bool {
|
||||
AVSpeechSynthesisVoice(language: localeID) != nil
|
||||
}
|
||||
|
||||
static func makeRecognizer(
|
||||
localSelection: String?,
|
||||
gatewaySelection: String?,
|
||||
|
||||
@@ -317,6 +317,36 @@ struct TalkGatewaySpeechClientTests {
|
||||
#expect(routing.route == .gatewayTalkSpeak)
|
||||
}
|
||||
|
||||
@Test func `system voice keeps BCP 47 locale separate from provider language`() {
|
||||
let manager = TalkModeManager(allowSimulatorCapture: true)
|
||||
manager._test_applyLoadedTalkConfig(
|
||||
Self.parseSpeechProvider("elevenlabs", speechLocale: "tr_TR"),
|
||||
providerSelection: .gatewayDefault)
|
||||
|
||||
let configured = manager._test_resolvedSpeechLanguages(
|
||||
directiveLanguage: nil,
|
||||
localSelection: TalkSpeechLocale.automaticID)
|
||||
let directive = manager._test_resolvedSpeechLanguages(
|
||||
directiveLanguage: "de_DE",
|
||||
localSelection: "fr-FR")
|
||||
let providerCompatible = manager._test_resolvedSpeechLanguages(
|
||||
directiveLanguage: "tr",
|
||||
localSelection: TalkSpeechLocale.automaticID)
|
||||
let unavailableDirective = manager._test_resolvedSpeechLanguages(
|
||||
directiveLanguage: "zz-ZZ",
|
||||
localSelection: TalkSpeechLocale.automaticID,
|
||||
isSystemVoiceAvailable: { $0 == "tr-TR" })
|
||||
|
||||
#expect(configured.provider == nil)
|
||||
#expect(configured.systemVoice == "tr-TR")
|
||||
#expect(directive.provider == nil)
|
||||
#expect(directive.systemVoice == "de-DE")
|
||||
#expect(providerCompatible.provider == "tr")
|
||||
#expect(providerCompatible.systemVoice == "tr")
|
||||
#expect(unavailableDirective.provider == nil)
|
||||
#expect(unavailableDirective.systemVoice == "tr-TR")
|
||||
}
|
||||
|
||||
@Test func `explicit realtime config keeps realtime relay`() {
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
config: [
|
||||
@@ -362,20 +392,23 @@ struct TalkGatewaySpeechClientTests {
|
||||
private static func parseSpeechProvider(
|
||||
_ provider: String,
|
||||
model: String? = "speech-model",
|
||||
interruptOnSpeech: Bool = false) -> TalkModeGatewayConfigState
|
||||
interruptOnSpeech: Bool = false,
|
||||
speechLocale: String? = nil) -> TalkModeGatewayConfigState
|
||||
{
|
||||
let providerConfig: [String: String] = model.map { ["model": $0] } ?? [:]
|
||||
var talkConfig: [String: Any] = [
|
||||
"provider": provider,
|
||||
"providers": [provider: providerConfig],
|
||||
"resolved": [
|
||||
"provider": provider,
|
||||
"config": providerConfig,
|
||||
],
|
||||
"interruptOnSpeech": interruptOnSpeech,
|
||||
]
|
||||
talkConfig["speechLocale"] = speechLocale
|
||||
return TalkModeGatewayConfigParser.parse(
|
||||
config: [
|
||||
"talk": [
|
||||
"provider": provider,
|
||||
"providers": [provider: providerConfig],
|
||||
"resolved": [
|
||||
"provider": provider,
|
||||
"config": providerConfig,
|
||||
],
|
||||
"interruptOnSpeech": interruptOnSpeech,
|
||||
],
|
||||
"talk": talkConfig,
|
||||
],
|
||||
defaultProvider: "elevenlabs",
|
||||
defaultModelIdFallback: "eleven_v3",
|
||||
|
||||
@@ -38,4 +38,75 @@ import Testing
|
||||
#expect(deviceLocale == "fr-FR")
|
||||
#expect(english == "en-US")
|
||||
}
|
||||
|
||||
@Test func speechSynthesisPrefersDirectiveLocale() {
|
||||
let locale = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: " tr_TR ",
|
||||
localSelection: "de-DE",
|
||||
gatewaySelection: "ru-RU",
|
||||
isVoiceAvailable: { _ in true })
|
||||
|
||||
#expect(locale == "tr-TR")
|
||||
}
|
||||
|
||||
@Test func speechSynthesisUsesLocalThenGatewayLocale() {
|
||||
let local = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: nil,
|
||||
localSelection: "de_DE",
|
||||
gatewaySelection: "ru-RU",
|
||||
isVoiceAvailable: { _ in true })
|
||||
let gateway = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: nil,
|
||||
localSelection: TalkSpeechLocale.automaticID,
|
||||
gatewaySelection: "ru_RU",
|
||||
isVoiceAvailable: { _ in true })
|
||||
|
||||
#expect(local == "de-DE")
|
||||
#expect(gateway == "ru-RU")
|
||||
}
|
||||
|
||||
@Test func automaticSpeechSynthesisUsesSystemDefaultWithoutGatewayLocale() {
|
||||
let locale = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: nil,
|
||||
localSelection: TalkSpeechLocale.automaticID,
|
||||
gatewaySelection: nil,
|
||||
isVoiceAvailable: { _ in true })
|
||||
|
||||
#expect(locale == nil)
|
||||
}
|
||||
|
||||
@Test func unavailableDirectiveFallsThroughToAvailableLocalVoice() {
|
||||
let locale = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: "zz-ZZ",
|
||||
localSelection: "fr-FR",
|
||||
gatewaySelection: "tr-TR",
|
||||
isVoiceAvailable: { $0 == "fr-FR" })
|
||||
|
||||
#expect(locale == "fr-FR")
|
||||
}
|
||||
|
||||
@Test func unavailableDirectiveAndLocalVoicesFallThroughToGatewayVoice() {
|
||||
let locale = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: "zz-ZZ",
|
||||
localSelection: "yy-YY",
|
||||
gatewaySelection: "tr_TR",
|
||||
isVoiceAvailable: { $0 == "tr-TR" })
|
||||
|
||||
#expect(locale == "tr-TR")
|
||||
}
|
||||
|
||||
@Test func unavailableCandidatesUseSystemDefaultOnlyAfterEveryCandidateFails() {
|
||||
var checkedLocaleIDs: [String] = []
|
||||
let locale = TalkSpeechLocale.resolvedSynthesisLocaleID(
|
||||
directiveLanguage: "zz-ZZ",
|
||||
localSelection: "fr-FR",
|
||||
gatewaySelection: "tr-TR",
|
||||
isVoiceAvailable: {
|
||||
checkedLocaleIDs.append($0)
|
||||
return false
|
||||
})
|
||||
|
||||
#expect(locale == nil)
|
||||
#expect(checkedLocaleIDs == ["zz-ZZ", "fr-FR", "tr-TR"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1492,7 +1492,7 @@ Defaults for Talk mode (macOS/iOS/Android and the browser Control UI).
|
||||
- macOS MLX playback runs through the bundled `openclaw-mlx-tts` helper when present, or an executable on `PATH`; `OPENCLAW_MLX_TTS_BIN` overrides the helper path for development.
|
||||
- `consultThinkingLevel` controls the thinking level for the full OpenClaw agent run behind Control UI Talk realtime `openclaw_agent_consult` calls. Leave unset to preserve normal session/model behavior.
|
||||
- `consultFastMode` sets a one-shot fast-mode override for Control UI Talk realtime consults without changing the session's normal fast-mode setting.
|
||||
- `speechLocale` sets the BCP 47 locale id used by Android, iOS, and macOS Talk speech recognition. Android also uses its language component to guide realtime input transcription. Leave unset to use the device default.
|
||||
- `speechLocale` sets the BCP 47 locale id used by Android, iOS, and macOS Talk speech recognition and by the iOS system-voice fallback. Android also uses its language component to guide realtime input transcription. Leave unset to use the device default.
|
||||
- `silenceTimeoutMs` controls how long Talk mode waits after user silence before it sends the transcript. Unset keeps the platform default pause window (`700 ms on macOS and Android, 900 ms on iOS`).
|
||||
- `realtime.instructions` appends provider-facing system instructions to OpenClaw's built-in realtime prompt, so voice style can be configured without losing default `openclaw_agent_consult` guidance.
|
||||
- `realtime.vadThreshold` sets the provider voice-activity threshold from `0` (most sensitive) to `1` (least sensitive). Unset keeps the provider default.
|
||||
|
||||
@@ -45,7 +45,7 @@ See [Configuration - agents](/gateway/config-agents) for:
|
||||
- `talk.*` (Talk mode)
|
||||
- `talk.consultThinkingLevel`: thinking level override for the full OpenClaw agent run behind Control UI Talk realtime consults
|
||||
- `talk.consultFastMode`: one-shot fast-mode override for Control UI Talk realtime consults
|
||||
- `talk.speechLocale`: optional BCP 47 locale id for Talk speech recognition on Android, iOS, and macOS
|
||||
- `talk.speechLocale`: optional BCP 47 locale id for Talk speech recognition on Android, iOS, and macOS, and for iOS system-voice fallback
|
||||
- `talk.silenceTimeoutMs`: when unset, Talk keeps the platform default pause window before sending the transcript (`700 ms on macOS and Android, 900 ms on iOS`)
|
||||
- `talk.realtime.consultRouting`: Gateway relay fallback for finalized realtime Talk transcripts that skip `openclaw_agent_consult`
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ agent turns use Codex OAuth.
|
||||
| `agentId` | configured default agent | Owns Talk sessions created without an explicit agent-scoped session key. |
|
||||
| `provider` | - | Active Talk TTS provider. Use `elevenlabs`, `mlx`, or `system` for macOS-local playback paths. |
|
||||
| `providers.<id>.voiceId` | - | ElevenLabs falls back to `ELEVENLABS_VOICE_ID` / `SAG_VOICE_ID`, or the first available voice with an API key. |
|
||||
| `speechLocale` | device default | BCP 47 locale for Android, iOS, and macOS native speech recognition. Apple Speech may use network services; Android also forwards the language component to realtime input transcription. |
|
||||
| `speechLocale` | device default | BCP 47 locale for Android, iOS, and macOS native speech recognition, plus the iOS system-voice fallback. Apple Speech may use network services; Android also forwards the language component to realtime input transcription. |
|
||||
| `providers.elevenlabs.modelId` | `eleven_v3` | |
|
||||
| `providers.mlx.modelId` | `mlx-community/Soprano-80M-bf16` | |
|
||||
| `providers.elevenlabs.apiKey` | - | Falls back to `ELEVENLABS_API_KEY` (or gateway shell profile if available). |
|
||||
|
||||
@@ -208,7 +208,7 @@ export const CORE_FIELD_HELP: Record<string, string> = {
|
||||
"talk.consultFastMode":
|
||||
"Use this to set true or false fast mode for the regular agent run behind Talk realtime consults.",
|
||||
"talk.speechLocale":
|
||||
'BCP 47 locale id for Talk speech recognition on device nodes, for example "ru-RU". Leave unset to use each device default.',
|
||||
'BCP 47 locale id for Talk speech recognition on device nodes and the iOS system-voice fallback, for example "ru-RU". Leave unset to use each device default.',
|
||||
"talk.interruptOnSpeech":
|
||||
"If true (default), stop assistant speech when the user starts speaking in Talk mode. Keep enabled for conversational turn-taking.",
|
||||
"talk.silenceTimeoutMs": `Milliseconds of user silence before Talk mode finalizes and sends the current transcript. Leave unset to keep the platform default pause window (${describeTalkSilenceTimeoutDefaults()}).`,
|
||||
|
||||
@@ -109,7 +109,7 @@ export type TalkConfig = {
|
||||
| "ultra";
|
||||
/** Optional fast mode override for the agent run behind Talk realtime consults. */
|
||||
consultFastMode?: boolean;
|
||||
/** BCP 47 locale id used for Talk speech recognition on device nodes. */
|
||||
/** BCP 47 locale id used for Talk speech recognition on device nodes and the iOS system-voice fallback. */
|
||||
speechLocale?: string;
|
||||
/** Stop speaking when user starts talking (default: true). */
|
||||
interruptOnSpeech?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user