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.
## [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
+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)
_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
+1 -1
View File
@@ -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"]
}
@@ -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() {
+1 -1
View File
@@ -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);
}