From 005a8d2c0911be4bdb5d2fb4c0bf10005833f11f Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 15 Apr 2026 14:49:51 -0700 Subject: [PATCH] Talk: add gateway TTS fallback when ElevenLabs is not configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no ElevenLabs API key is set, Talk mode now calls the gateway's talk.speak method (which supports Edge TTS, Kokoro, and other speech providers) instead of falling directly to the macOS system voice. This gives users access to natural-sounding neural voices (e.g. Microsoft Edge TTS) without requiring an ElevenLabs subscription. The macOS system voice remains as a last-resort fallback if the gateway call fails. Flow: ElevenLabs key → ElevenLabs | No key → gateway talk.speak | fail → system voice Co-Authored-By: boydyngo --- .../Sources/OpenClaw/TalkModeRuntime.swift | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 9ef7b010fa80f..38ab4861e432e 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -442,7 +442,7 @@ actor TalkModeRuntime { if let apiKey = input.apiKey, !apiKey.isEmpty, let voiceId = input.voiceId { try await self.playElevenLabs(input: input, apiKey: apiKey, voiceId: voiceId) } else { - try await self.playSystemVoice(input: input) + try await self.playGatewayTTS(input: input) } } catch { self.ttsLogger @@ -643,6 +643,47 @@ actor TalkModeRuntime { return await self.playMP3(stream: stream) } + private func playGatewayTTS(input: TalkPlaybackInput) async throws { + self.ttsLogger.info("talk gateway TTS start chars=\(input.cleanedText.count, privacy: .public)") + + let params: [String: AnyCodable] = ["text": AnyCodable(input.cleanedText)] + let data = try await GatewayConnection.shared.request( + method: "talk.speak", + params: params, + timeoutMs: Double(input.synthTimeoutSeconds * 1000)) + + struct TalkSpeakResponse: Decodable { + let audioBase64: String? + let provider: String? + let mimeType: String? + } + let response = try JSONDecoder().decode(TalkSpeakResponse.self, from: data) + guard let base64 = response.audioBase64, !base64.isEmpty, + let audioData = Data(base64Encoded: base64) + else { + throw NSError( + domain: "TalkGatewayTTS", code: 1, + userInfo: [NSLocalizedDescriptionKey: "gateway returned no audio"]) + } + + let provider = response.provider ?? "edge" + self.ttsLogger.info( + "talk gateway TTS received \(audioData.count, privacy: .public) bytes " + + "provider=\(provider, privacy: .public)") + + guard self.isCurrent(input.generation) else { return } + + if self.interruptOnSpeech { + guard await self.prepareForPlayback(generation: input.generation) else { return } + } + await MainActor.run { TalkModeController.shared.updatePhase(.speaking) } + self.phase = .speaking + + let result = await TalkAudioPlayer.shared.play(data: audioData) + self.ttsLogger.info( + "talk gateway TTS done finished=\(result.finished, privacy: .public)") + } + private func playSystemVoice(input: TalkPlaybackInput) async throws { self.ttsLogger.info("talk system voice start chars=\(input.cleanedText.count, privacy: .public)") if self.interruptOnSpeech { @@ -800,8 +841,8 @@ extension TalkModeRuntime { do { let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded( - method: .talkConfig, - params: ["includeSecrets": AnyCodable(true)], + method: .configGet, + params: nil, timeoutMs: 8000) let talk = snap.config?["talk"]?.dictionaryValue let ui = snap.config?["ui"]?.dictionaryValue