From 6d37734cf9e5f9b6d9e7fba2d30077183282bcbf Mon Sep 17 00:00:00 2001 From: techartdev Date: Sat, 21 Feb 2026 01:23:57 +0200 Subject: [PATCH] Update version to 0.1.46 and enhance TTS resilience with automatic voice fallback and error reporting --- CHANGELOG.md | 7 + custom_components/openclaw/__init__.py | 2 +- custom_components/openclaw/manifest.json | 2 +- .../openclaw/www/openclaw-chat-card.js | 185 ++++++++++++++++-- www/openclaw-chat-card.js | 2 +- 5 files changed, 176 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb4394..583b5a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. +## [0.1.46] - 2026-02-21 + +### Fixed +- Improved chat-card TTS resilience when the preferred language voice (for example `bg-BG`) is temporarily unavailable in the browser. +- Added automatic fallback to an available voice/language instead of hard failing speech output. +- TTS error status now includes browser-provided error reason when available. + ## [0.1.45] - 2026-02-21 ### Fixed diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 32aff1c..87c9061 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -92,7 +92,7 @@ _CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME # URL at which the card JS is served (registered via register_static_path) _CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}" # Versioned URL used for Lovelace resource registration to avoid stale browser cache -_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.45" +_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.46" OpenClawConfigEntry = ConfigEntry diff --git a/custom_components/openclaw/manifest.json b/custom_components/openclaw/manifest.json index 96684a7..d162c17 100644 --- a/custom_components/openclaw/manifest.json +++ b/custom_components/openclaw/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues", "requirements": [], - "version": "0.1.45", + "version": "0.1.46", "dependencies": ["conversation"], "after_dependencies": ["hassio", "lovelace"] } diff --git a/custom_components/openclaw/www/openclaw-chat-card.js b/custom_components/openclaw/www/openclaw-chat-card.js index 3cad1af..a59420d 100644 --- a/custom_components/openclaw/www/openclaw-chat-card.js +++ b/custom_components/openclaw/www/openclaw-chat-card.js @@ -74,6 +74,7 @@ class OpenClawChatCard extends HTMLElement { this._allowBraveWebSpeechIntegration = false; this._voiceProviderIntegration = "browser"; this._preferredAssistSttEngine = null; + this._preferredAssistTtsEngine = null; this._voiceBackendBlocked = false; this._voiceStopRequested = false; this._assistRecordingActive = false; @@ -381,6 +382,7 @@ class OpenClawChatCard extends HTMLElement { const preferredPipeline = pipelines.find((pipeline) => pipeline?.id === preferredPipelineId) || pipelines[0]; this._preferredAssistSttEngine = preferredPipeline?.stt_engine || null; + this._preferredAssistTtsEngine = preferredPipeline?.tts_engine || null; const sttLanguage = preferredPipeline?.stt_language || preferredPipeline?.language; const ttsLanguage = @@ -1241,29 +1243,77 @@ class OpenClawChatCard extends HTMLElement { if (!("speechSynthesis" in window)) return; // Strip markdown for TTS const plain = text.replace(/[*_`#\[\]()]/g, ""); - const language = this._getSpeechSynthesisLanguage(); + const preferredLanguage = this._getSpeechSynthesisLanguage(); + const fallbackLanguage = this._normalizeSpeechLanguage( + this._hass?.locale?.language || this._hass?.selectedLanguage || this._hass?.language || navigator.language || "en-US" + ); + let attemptedFallback = false; - const speakNow = () => { - const utterance = new SpeechSynthesisUtterance(plain); - utterance.lang = language; + const pickVoice = (targetLanguage, voices) => { + const normalizedTarget = String(targetLanguage || "").toLowerCase(); + const targetBase = normalizedTarget.split("-")[0]; - const voices = speechSynthesis.getVoices() || []; - if (voices.length) { - const exactVoice = voices.find( - (voice) => String(voice.lang || "").toLowerCase() === language.toLowerCase() + const exactVoice = voices.find( + (voice) => String(voice.lang || "").toLowerCase() === normalizedTarget + ); + if (exactVoice) { + return { voice: exactVoice, lang: exactVoice.lang || targetLanguage, matched: true }; + } + + if (targetBase) { + const baseVoice = voices.find((voice) => + String(voice.lang || "").toLowerCase().startsWith(`${targetBase}-`) ); - const prefix = language.split("-")[0]?.toLowerCase(); - const languageVoice = - exactVoice || - voices.find((voice) => String(voice.lang || "").toLowerCase().startsWith(`${prefix}-`)); - if (languageVoice) { - utterance.voice = languageVoice; + if (baseVoice) { + return { voice: baseVoice, lang: baseVoice.lang || targetLanguage, matched: true }; } } - utterance.onerror = () => { - this._voiceStatus = `TTS error for language ${language}`; + if (voices.length) { + const defaultVoice = voices.find((voice) => voice.default) || voices[0]; + return { + voice: defaultVoice, + lang: defaultVoice?.lang || fallbackLanguage || "en-US", + matched: false, + }; + } + + return { voice: null, lang: targetLanguage, matched: false }; + }; + + const speakNow = (targetLanguage, allowFallback = true) => { + const utterance = new SpeechSynthesisUtterance(plain); + utterance.lang = targetLanguage; + + const voices = speechSynthesis.getVoices() || []; + if (voices.length) { + const selection = pickVoice(targetLanguage, voices); + if (selection.voice) { + utterance.voice = selection.voice; + } + if (selection.lang) { + utterance.lang = selection.lang; + } + } + + utterance.onerror = (event) => { + if (allowFallback && !attemptedFallback && targetLanguage !== fallbackLanguage) { + attemptedFallback = true; + speakNow(fallbackLanguage, false); + return; + } + this._voiceStatus = "Browser TTS failed, trying Home Assistant TTS…"; this._render(); + this._speakViaHomeAssistantTts(plain, targetLanguage).then((ok) => { + if (ok) { + this._voiceStatus = ""; + this._render(); + return; + } + const reason = event?.error ? ` (${event.error})` : ""; + this._voiceStatus = `TTS error for language ${targetLanguage}${reason}`; + this._render(); + }); }; try { @@ -1276,21 +1326,118 @@ class OpenClawChatCard extends HTMLElement { const loadedVoices = speechSynthesis.getVoices() || []; if (loadedVoices.length) { - speakNow(); + speakNow(preferredLanguage); return; } const handleVoicesChanged = () => { speechSynthesis.removeEventListener("voiceschanged", handleVoicesChanged); - speakNow(); + speakNow(preferredLanguage); }; speechSynthesis.addEventListener("voiceschanged", handleVoicesChanged); setTimeout(() => { speechSynthesis.removeEventListener("voiceschanged", handleVoicesChanged); - speakNow(); + speakNow(preferredLanguage); }, 800); } + async _speakViaHomeAssistantTts(text, language) { + if (!this._hass) return false; + + const engine = this._preferredAssistTtsEngine; + if (!engine) return false; + + const token = this._hass?.auth?.data?.access_token; + const headers = { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const payloads = [ + { + platform: engine, + message: text, + language, + cache: true, + }, + { + engine_id: engine, + message: text, + language, + cache: true, + }, + { + tts_engine_id: engine, + message: text, + language, + cache: true, + }, + ]; + + let audioPath = null; + for (const payload of payloads) { + try { + const response = await fetch("/api/tts_get_url", { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + if (!response.ok) continue; + + const body = await response.json(); + const candidate = body?.url || body?.path || body?.audio_url || body?.audioUrl; + if (!candidate) continue; + + audioPath = String(candidate).startsWith("http") + ? String(candidate) + : String(candidate).startsWith("/") + ? String(candidate) + : `/${String(candidate)}`; + break; + } catch (err) { + console.debug("OpenClaw: HA TTS URL request failed", err); + } + } + + if (!audioPath) return false; + + try { + const audioResponse = await fetch(audioPath, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!audioResponse.ok) return false; + + const audioBlob = await audioResponse.blob(); + const objectUrl = URL.createObjectURL(audioBlob); + + await new Promise((resolve, reject) => { + const audio = new Audio(); + audio.src = objectUrl; + audio.onended = () => { + URL.revokeObjectURL(objectUrl); + resolve(true); + }; + audio.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error("audio_playback_failed")); + }; + + const playPromise = audio.play(); + if (playPromise && typeof playPromise.catch === "function") { + playPromise.catch((err) => { + URL.revokeObjectURL(objectUrl); + reject(err); + }); + } + }); + + return true; + } catch (err) { + console.debug("OpenClaw: HA TTS playback failed", err); + return false; + } + } + // ── File attachments ──────────────────────────────────────────────── _handleFileAttachment() { diff --git a/www/openclaw-chat-card.js b/www/openclaw-chat-card.js index 214a70a..7d9937f 100644 --- a/www/openclaw-chat-card.js +++ b/www/openclaw-chat-card.js @@ -1,7 +1,7 @@ (async () => { try { if (!customElements.get("openclaw-chat-card")) { - const src = "/openclaw/openclaw-chat-card.js?v=0.1.45"; + const src = "/openclaw/openclaw-chat-card.js?v=0.1.46"; console.info("OpenClaw loader importing", src); await import(src); }