Update version to 0.1.46 and enhance TTS resilience with automatic voice fallback and error reporting

This commit is contained in:
techartdev
2026-02-21 01:23:57 +02:00
parent 14c5d946a5
commit 6d37734cf9
5 changed files with 176 additions and 22 deletions
+7
View File
@@ -2,6 +2,13 @@
All notable changes to the OpenClaw Home Assistant Integration will be documented in this file. 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 ## [0.1.45] - 2026-02-21
### Fixed ### Fixed
+1 -1
View File
@@ -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) # URL at which the card JS is served (registered via register_static_path)
_CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}" _CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}"
# Versioned URL used for Lovelace resource registration to avoid stale browser cache # 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 OpenClawConfigEntry = ConfigEntry
+1 -1
View File
@@ -8,7 +8,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues", "issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
"requirements": [], "requirements": [],
"version": "0.1.45", "version": "0.1.46",
"dependencies": ["conversation"], "dependencies": ["conversation"],
"after_dependencies": ["hassio", "lovelace"] "after_dependencies": ["hassio", "lovelace"]
} }
@@ -74,6 +74,7 @@ class OpenClawChatCard extends HTMLElement {
this._allowBraveWebSpeechIntegration = false; this._allowBraveWebSpeechIntegration = false;
this._voiceProviderIntegration = "browser"; this._voiceProviderIntegration = "browser";
this._preferredAssistSttEngine = null; this._preferredAssistSttEngine = null;
this._preferredAssistTtsEngine = null;
this._voiceBackendBlocked = false; this._voiceBackendBlocked = false;
this._voiceStopRequested = false; this._voiceStopRequested = false;
this._assistRecordingActive = false; this._assistRecordingActive = false;
@@ -381,6 +382,7 @@ class OpenClawChatCard extends HTMLElement {
const preferredPipeline = const preferredPipeline =
pipelines.find((pipeline) => pipeline?.id === preferredPipelineId) || pipelines[0]; pipelines.find((pipeline) => pipeline?.id === preferredPipelineId) || pipelines[0];
this._preferredAssistSttEngine = preferredPipeline?.stt_engine || null; this._preferredAssistSttEngine = preferredPipeline?.stt_engine || null;
this._preferredAssistTtsEngine = preferredPipeline?.tts_engine || null;
const sttLanguage = preferredPipeline?.stt_language || preferredPipeline?.language; const sttLanguage = preferredPipeline?.stt_language || preferredPipeline?.language;
const ttsLanguage = const ttsLanguage =
@@ -1241,29 +1243,77 @@ class OpenClawChatCard extends HTMLElement {
if (!("speechSynthesis" in window)) return; if (!("speechSynthesis" in window)) return;
// Strip markdown for TTS // Strip markdown for TTS
const plain = text.replace(/[*_`#\[\]()]/g, ""); 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 pickVoice = (targetLanguage, voices) => {
const utterance = new SpeechSynthesisUtterance(plain); const normalizedTarget = String(targetLanguage || "").toLowerCase();
utterance.lang = language; const targetBase = normalizedTarget.split("-")[0];
const voices = speechSynthesis.getVoices() || []; const exactVoice = voices.find(
if (voices.length) { (voice) => String(voice.lang || "").toLowerCase() === normalizedTarget
const exactVoice = voices.find( );
(voice) => String(voice.lang || "").toLowerCase() === language.toLowerCase() 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(); if (baseVoice) {
const languageVoice = return { voice: baseVoice, lang: baseVoice.lang || targetLanguage, matched: true };
exactVoice ||
voices.find((voice) => String(voice.lang || "").toLowerCase().startsWith(`${prefix}-`));
if (languageVoice) {
utterance.voice = languageVoice;
} }
} }
utterance.onerror = () => { if (voices.length) {
this._voiceStatus = `TTS error for language ${language}`; 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._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 { try {
@@ -1276,21 +1326,118 @@ class OpenClawChatCard extends HTMLElement {
const loadedVoices = speechSynthesis.getVoices() || []; const loadedVoices = speechSynthesis.getVoices() || [];
if (loadedVoices.length) { if (loadedVoices.length) {
speakNow(); speakNow(preferredLanguage);
return; return;
} }
const handleVoicesChanged = () => { const handleVoicesChanged = () => {
speechSynthesis.removeEventListener("voiceschanged", handleVoicesChanged); speechSynthesis.removeEventListener("voiceschanged", handleVoicesChanged);
speakNow(); speakNow(preferredLanguage);
}; };
speechSynthesis.addEventListener("voiceschanged", handleVoicesChanged); speechSynthesis.addEventListener("voiceschanged", handleVoicesChanged);
setTimeout(() => { setTimeout(() => {
speechSynthesis.removeEventListener("voiceschanged", handleVoicesChanged); speechSynthesis.removeEventListener("voiceschanged", handleVoicesChanged);
speakNow(); speakNow(preferredLanguage);
}, 800); }, 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 ──────────────────────────────────────────────── // ── File attachments ────────────────────────────────────────────────
_handleFileAttachment() { _handleFileAttachment() {
+1 -1
View File
@@ -1,7 +1,7 @@
(async () => { (async () => {
try { try {
if (!customElements.get("openclaw-chat-card")) { 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); console.info("OpenClaw loader importing", src);
await import(src); await import(src);
} }