Add voice input provider options and update integration settings

- Introduced configurable voice input provider option: `browser` or `assist_stt`.
- Enhanced chat card to support Home Assistant STT transcription mode for manual mic input.
- Updated integration settings to expose voice provider through websocket payload.
- Adjusted continuous voice mode availability based on selected voice provider.
- Updated version to 0.1.32 in manifest and chat card files.
This commit is contained in:
techartdev
2026-02-20 21:31:01 +02:00
parent bd10bc577e
commit 9f226c2ea9
10 changed files with 317 additions and 8 deletions
+10
View File
@@ -2,6 +2,16 @@
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.32] - 2026-02-20
### Added
- Added configurable voice input provider option: `browser` or `assist_stt`.
- Chat card now supports Home Assistant STT transcription mode (`assist_stt`) for manual mic input.
### Changed
- Voice provider is now exposed through integration settings websocket payload and card configuration handling.
- Continuous voice mode remains available only for browser voice provider.
## [0.1.31] - 2026-02-20 ## [0.1.31] - 2026-02-20
### Fixed ### Fixed
+1
View File
@@ -123,6 +123,7 @@ When enabled, OpenClaw tool-call responses can execute Home Assistant services.
- **Wake word enabled** - **Wake word enabled**
- **Wake word** (default: `hey openclaw`) - **Wake word** (default: `hey openclaw`)
- **Always voice mode** (continuous listening while card is open) - **Always voice mode** (continuous listening while card is open)
- **Voice input provider** (`browser` or `assist_stt`)
--- ---
+7 -1
View File
@@ -47,6 +47,7 @@ from .const import (
CONF_WAKE_WORD_ENABLED, CONF_WAKE_WORD_ENABLED,
CONF_ALWAYS_VOICE_MODE, CONF_ALWAYS_VOICE_MODE,
CONF_ALLOW_BRAVE_WEBSPEECH, CONF_ALLOW_BRAVE_WEBSPEECH,
CONF_VOICE_PROVIDER,
CONTEXT_STRATEGY_TRUNCATE, CONTEXT_STRATEGY_TRUNCATE,
DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY,
@@ -56,6 +57,7 @@ from .const import (
DEFAULT_WAKE_WORD_ENABLED, DEFAULT_WAKE_WORD_ENABLED,
DEFAULT_ALWAYS_VOICE_MODE, DEFAULT_ALWAYS_VOICE_MODE,
DEFAULT_ALLOW_BRAVE_WEBSPEECH, DEFAULT_ALLOW_BRAVE_WEBSPEECH,
DEFAULT_VOICE_PROVIDER,
DOMAIN, DOMAIN,
EVENT_MESSAGE_RECEIVED, EVENT_MESSAGE_RECEIVED,
OPENCLAW_CONFIG_REL_PATH, OPENCLAW_CONFIG_REL_PATH,
@@ -76,7 +78,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.30" _CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.32"
OpenClawConfigEntry = ConfigEntry OpenClawConfigEntry = ConfigEntry
@@ -689,6 +691,10 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None:
CONF_ALLOW_BRAVE_WEBSPEECH, CONF_ALLOW_BRAVE_WEBSPEECH,
DEFAULT_ALLOW_BRAVE_WEBSPEECH, DEFAULT_ALLOW_BRAVE_WEBSPEECH,
), ),
CONF_VOICE_PROVIDER: options.get(
CONF_VOICE_PROVIDER,
DEFAULT_VOICE_PROVIDER,
),
"language": hass.config.language, "language": hass.config.language,
}, },
) )
@@ -46,6 +46,7 @@ from .const import (
CONF_WAKE_WORD_ENABLED, CONF_WAKE_WORD_ENABLED,
CONF_ALWAYS_VOICE_MODE, CONF_ALWAYS_VOICE_MODE,
CONF_ALLOW_BRAVE_WEBSPEECH, CONF_ALLOW_BRAVE_WEBSPEECH,
CONF_VOICE_PROVIDER,
CONTEXT_STRATEGY_CLEAR, CONTEXT_STRATEGY_CLEAR,
CONTEXT_STRATEGY_TRUNCATE, CONTEXT_STRATEGY_TRUNCATE,
DEFAULT_GATEWAY_HOST, DEFAULT_GATEWAY_HOST,
@@ -58,6 +59,7 @@ from .const import (
DEFAULT_WAKE_WORD_ENABLED, DEFAULT_WAKE_WORD_ENABLED,
DEFAULT_ALWAYS_VOICE_MODE, DEFAULT_ALWAYS_VOICE_MODE,
DEFAULT_ALLOW_BRAVE_WEBSPEECH, DEFAULT_ALLOW_BRAVE_WEBSPEECH,
DEFAULT_VOICE_PROVIDER,
DOMAIN, DOMAIN,
OPENCLAW_CONFIG_REL_PATH, OPENCLAW_CONFIG_REL_PATH,
) )
@@ -474,6 +476,13 @@ class OpenClawOptionsFlow(OptionsFlow):
DEFAULT_ALLOW_BRAVE_WEBSPEECH, DEFAULT_ALLOW_BRAVE_WEBSPEECH,
), ),
): bool, ): bool,
vol.Optional(
CONF_VOICE_PROVIDER,
default=options.get(
CONF_VOICE_PROVIDER,
DEFAULT_VOICE_PROVIDER,
),
): vol.In(["browser", "assist_stt"]),
} }
), ),
) )
+2
View File
@@ -32,6 +32,7 @@ CONF_WAKE_WORD_ENABLED = "wake_word_enabled"
CONF_WAKE_WORD = "wake_word" CONF_WAKE_WORD = "wake_word"
CONF_ALWAYS_VOICE_MODE = "always_voice_mode" CONF_ALWAYS_VOICE_MODE = "always_voice_mode"
CONF_ALLOW_BRAVE_WEBSPEECH = "allow_brave_webspeech" CONF_ALLOW_BRAVE_WEBSPEECH = "allow_brave_webspeech"
CONF_VOICE_PROVIDER = "voice_provider"
DEFAULT_INCLUDE_EXPOSED_CONTEXT = True DEFAULT_INCLUDE_EXPOSED_CONTEXT = True
DEFAULT_CONTEXT_MAX_CHARS = 13000 DEFAULT_CONTEXT_MAX_CHARS = 13000
@@ -41,6 +42,7 @@ DEFAULT_WAKE_WORD_ENABLED = False
DEFAULT_WAKE_WORD = "hey openclaw" DEFAULT_WAKE_WORD = "hey openclaw"
DEFAULT_ALWAYS_VOICE_MODE = False DEFAULT_ALWAYS_VOICE_MODE = False
DEFAULT_ALLOW_BRAVE_WEBSPEECH = False DEFAULT_ALLOW_BRAVE_WEBSPEECH = False
DEFAULT_VOICE_PROVIDER = "browser"
CONTEXT_STRATEGY_TRUNCATE = "truncate" CONTEXT_STRATEGY_TRUNCATE = "truncate"
CONTEXT_STRATEGY_CLEAR = "clear" CONTEXT_STRATEGY_CLEAR = "clear"
+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.31", "version": "0.1.32",
"dependencies": ["conversation"], "dependencies": ["conversation"],
"after_dependencies": ["hassio", "lovelace"] "after_dependencies": ["hassio", "lovelace"]
} }
+2 -1
View File
@@ -44,7 +44,8 @@
"wake_word_enabled": "Require wake word", "wake_word_enabled": "Require wake word",
"wake_word": "Wake word", "wake_word": "Wake word",
"always_voice_mode": "Always-on voice mode", "always_voice_mode": "Always-on voice mode",
"allow_brave_webspeech": "Allow Web Speech in Brave (experimental)" "allow_brave_webspeech": "Allow Web Speech in Brave (experimental)",
"voice_provider": "Voice input provider (browser or HA STT)"
} }
} }
} }
@@ -44,7 +44,8 @@
"wake_word_enabled": "Require wake word", "wake_word_enabled": "Require wake word",
"wake_word": "Wake word", "wake_word": "Wake word",
"always_voice_mode": "Always-on voice mode", "always_voice_mode": "Always-on voice mode",
"allow_brave_webspeech": "Allow Web Speech in Brave (experimental)" "allow_brave_webspeech": "Allow Web Speech in Brave (experimental)",
"voice_provider": "Voice input provider (browser or HA STT)"
} }
} }
} }
@@ -13,7 +13,7 @@
* + subscribes to openclaw_message_received events. * + subscribes to openclaw_message_received events.
*/ */
const CARD_VERSION = "0.2.9"; const CARD_VERSION = "0.3.0";
// Max time (ms) to show the thinking indicator before falling back to an error // Max time (ms) to show the thinking indicator before falling back to an error
const THINKING_TIMEOUT_MS = 120_000; const THINKING_TIMEOUT_MS = 120_000;
@@ -72,8 +72,19 @@ class OpenClawChatCard extends HTMLElement {
this._integrationVoiceLanguage = null; this._integrationVoiceLanguage = null;
this._integrationTtsLanguage = null; this._integrationTtsLanguage = null;
this._allowBraveWebSpeechIntegration = false; this._allowBraveWebSpeechIntegration = false;
this._voiceProviderIntegration = "browser";
this._preferredAssistSttEngine = null;
this._voiceBackendBlocked = false; this._voiceBackendBlocked = false;
this._voiceStopRequested = false; this._voiceStopRequested = false;
this._assistRecordingActive = false;
this._assistAudioStream = null;
this._assistAudioContext = null;
this._assistAudioSource = null;
this._assistAudioProcessor = null;
this._assistAudioSilenceGain = null;
this._assistAudioChunks = [];
this._assistInputSampleRate = 16000;
this._assistAutoStopTimer = null;
} }
// ── HA card interface ─────────────────────────────────────────────── // ── HA card interface ───────────────────────────────────────────────
@@ -94,6 +105,7 @@ class OpenClawChatCard extends HTMLElement {
show_voice_button: config.show_voice_button !== false, show_voice_button: config.show_voice_button !== false,
show_clear_button: config.show_clear_button !== false, show_clear_button: config.show_clear_button !== false,
allow_brave_webspeech: config.allow_brave_webspeech === true, allow_brave_webspeech: config.allow_brave_webspeech === true,
voice_provider: config.voice_provider || null,
session_id: config.session_id || null, session_id: config.session_id || null,
...config, ...config,
}; };
@@ -317,6 +329,8 @@ class OpenClawChatCard extends HTMLElement {
this._wakeWord = (result?.wake_word || "hey openclaw").toString().trim().toLowerCase(); this._wakeWord = (result?.wake_word || "hey openclaw").toString().trim().toLowerCase();
this._alwaysVoiceMode = !!result?.always_voice_mode; this._alwaysVoiceMode = !!result?.always_voice_mode;
this._allowBraveWebSpeechIntegration = !!result?.allow_brave_webspeech; this._allowBraveWebSpeechIntegration = !!result?.allow_brave_webspeech;
this._voiceProviderIntegration =
result?.voice_provider === "assist_stt" ? "assist_stt" : "browser";
this._integrationVoiceLanguage = result?.language this._integrationVoiceLanguage = result?.language
? this._normalizeSpeechLanguage(result.language) ? this._normalizeSpeechLanguage(result.language)
: null; : null;
@@ -337,6 +351,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;
const sttLanguage = preferredPipeline?.stt_language || preferredPipeline?.language; const sttLanguage = preferredPipeline?.stt_language || preferredPipeline?.language;
const ttsLanguage = const ttsLanguage =
@@ -536,7 +551,258 @@ class OpenClawChatCard extends HTMLElement {
return brandMatch || ua.includes("brave") || !!navigator.brave; return brandMatch || ua.includes("brave") || !!navigator.brave;
} }
_getVoiceProvider() {
const configured = this._config.voice_provider;
if (configured === "assist_stt" || configured === "browser") {
return configured;
}
return this._voiceProviderIntegration || "browser";
}
_startVoiceRecognition() { _startVoiceRecognition() {
const provider = this._getVoiceProvider();
if (provider === "assist_stt") {
this._startAssistSttRecognition();
return;
}
this._startBrowserVoiceRecognition();
}
async _startAssistSttRecognition() {
if (!this._hass) return;
if (this._isVoiceMode) {
this._voiceStatus =
"Continuous voice mode currently requires browser speech. Switch voice provider to browser or disable continuous mode.";
this._render();
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
this._voiceStatus = "Microphone capture not supported by this browser.";
this._render();
return;
}
if (!this._preferredAssistSttEngine) {
this._voiceStatus =
"No Assist STT engine found in preferred voice pipeline. Configure Voice settings first.";
this._render();
return;
}
try {
this._assistAudioChunks = [];
this._assistAudioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
this._assistAudioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 16000,
});
this._assistInputSampleRate = this._assistAudioContext.sampleRate || 16000;
this._assistAudioSource = this._assistAudioContext.createMediaStreamSource(this._assistAudioStream);
this._assistAudioProcessor = this._assistAudioContext.createScriptProcessor(4096, 1, 1);
this._assistAudioSilenceGain = this._assistAudioContext.createGain();
this._assistAudioSilenceGain.gain.value = 0;
this._assistAudioProcessor.onaudioprocess = (event) => {
const input = event.inputBuffer.getChannelData(0);
this._assistAudioChunks.push(new Float32Array(input));
};
this._assistAudioSource.connect(this._assistAudioProcessor);
this._assistAudioProcessor.connect(this._assistAudioSilenceGain);
this._assistAudioSilenceGain.connect(this._assistAudioContext.destination);
this._assistRecordingActive = true;
this._recognition = { engine: "assist_stt" };
this._voiceStatus = "Listening with Home Assistant STT…";
this._render();
if (this._assistAutoStopTimer) {
clearTimeout(this._assistAutoStopTimer);
}
this._assistAutoStopTimer = setTimeout(() => {
this._stopAssistSttRecognition(true);
}, 4500);
} catch (err) {
console.error("OpenClaw: failed to start Assist STT recording", err);
this._voiceStatus = "Could not start microphone recording for Assist STT.";
this._assistRecordingActive = false;
this._recognition = null;
this._render();
}
}
async _stopAssistSttRecognition(processAudio) {
if (this._assistAutoStopTimer) {
clearTimeout(this._assistAutoStopTimer);
this._assistAutoStopTimer = null;
}
if (this._assistAudioProcessor) {
this._assistAudioProcessor.disconnect();
this._assistAudioProcessor.onaudioprocess = null;
this._assistAudioProcessor = null;
}
if (this._assistAudioSource) {
this._assistAudioSource.disconnect();
this._assistAudioSource = null;
}
if (this._assistAudioSilenceGain) {
this._assistAudioSilenceGain.disconnect();
this._assistAudioSilenceGain = null;
}
if (this._assistAudioStream) {
for (const track of this._assistAudioStream.getTracks()) {
track.stop();
}
this._assistAudioStream = null;
}
if (this._assistAudioContext) {
try {
await this._assistAudioContext.close();
} catch (e) {
// ignore
}
this._assistAudioContext = null;
}
this._assistRecordingActive = false;
this._recognition = null;
if (!processAudio) {
this._voiceStatus = "";
this._render();
return;
}
if (!this._assistAudioChunks.length) {
this._voiceStatus = "No speech detected. Tap mic and try again.";
this._render();
return;
}
this._voiceStatus = "Transcribing with Home Assistant STT…";
this._render();
try {
const language = this._getSpeechRecognitionLanguage();
const wavBuffer = this._encodeWavFromChunks(this._assistAudioChunks, this._assistInputSampleRate);
this._assistAudioChunks = [];
const token = this._hass?.auth?.data?.access_token;
const response = await fetch(
`/api/stt/${encodeURIComponent(this._preferredAssistSttEngine)}`,
{
method: "POST",
headers: {
"Content-Type": "audio/wav",
"X-Speech-Content":
`format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=1; language=${language}`,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: new Blob([wavBuffer], { type: "audio/wav" }),
}
);
if (!response.ok) {
this._voiceStatus = `Assist STT error (${response.status}).`;
this._render();
return;
}
const sttResult = await response.json();
const transcript = String(sttResult?.text || "").trim();
if (!transcript) {
this._voiceStatus = "No speech recognized by Assist STT.";
this._render();
return;
}
this._voiceStatus = "Sending…";
this._render();
this._sendMessage(transcript);
} catch (err) {
console.error("OpenClaw: Assist STT transcription failed", err);
this._voiceStatus = "Assist STT transcription failed.";
this._render();
}
}
_downsampleBuffer(input, inputSampleRate, outputSampleRate) {
if (outputSampleRate >= inputSampleRate) {
return input;
}
const sampleRateRatio = inputSampleRate / outputSampleRate;
const outputLength = Math.round(input.length / sampleRateRatio);
const outputBuffer = new Float32Array(outputLength);
let outputOffset = 0;
let inputOffset = 0;
while (outputOffset < outputLength) {
const nextInputOffset = Math.round((outputOffset + 1) * sampleRateRatio);
let accumulator = 0;
let count = 0;
for (let idx = inputOffset; idx < nextInputOffset && idx < input.length; idx += 1) {
accumulator += input[idx];
count += 1;
}
outputBuffer[outputOffset] = count > 0 ? accumulator / count : 0;
outputOffset += 1;
inputOffset = nextInputOffset;
}
return outputBuffer;
}
_encodeWavFromChunks(chunks, inputSampleRate) {
let totalLength = 0;
for (const chunk of chunks) {
totalLength += chunk.length;
}
const merged = new Float32Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
const downsampled = this._downsampleBuffer(merged, inputSampleRate, 16000);
const buffer = new ArrayBuffer(44 + downsampled.length * 2);
const view = new DataView(buffer);
const writeString = (dataView, start, value) => {
for (let idx = 0; idx < value.length; idx += 1) {
dataView.setUint8(start + idx, value.charCodeAt(idx));
}
};
writeString(view, 0, "RIFF");
view.setUint32(4, 36 + downsampled.length * 2, true);
writeString(view, 8, "WAVE");
writeString(view, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, 16000, true);
view.setUint32(28, 16000 * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeString(view, 36, "data");
view.setUint32(40, downsampled.length * 2, true);
let wavOffset = 44;
for (let idx = 0; idx < downsampled.length; idx += 1) {
const sample = Math.max(-1, Math.min(1, downsampled[idx]));
view.setInt16(wavOffset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
wavOffset += 2;
}
return buffer;
}
_startBrowserVoiceRecognition() {
this._voiceBackendBlocked = false; this._voiceBackendBlocked = false;
this._voiceStopRequested = false; this._voiceStopRequested = false;
const allowBraveWebSpeech = const allowBraveWebSpeech =
@@ -683,9 +949,15 @@ class OpenClawChatCard extends HTMLElement {
} }
_stopVoiceRecognition() { _stopVoiceRecognition() {
if (this._assistRecordingActive) {
this._stopAssistSttRecognition(true);
}
if (this._recognition) { if (this._recognition) {
if (this._recognition.engine !== "assist_stt") {
this._voiceStopRequested = true; this._voiceStopRequested = true;
this._recognition.abort(); this._recognition.abort();
}
this._recognition = null; this._recognition = null;
} }
if (this._voiceRetryTimer) { if (this._voiceRetryTimer) {
@@ -725,6 +997,13 @@ class OpenClawChatCard extends HTMLElement {
} }
_toggleVoiceMode() { _toggleVoiceMode() {
if (!this._isVoiceMode && this._getVoiceProvider() === "assist_stt") {
this._voiceStatus =
"Continuous voice mode is only available with browser voice provider.";
this._render();
return;
}
this._isVoiceMode = !this._isVoiceMode; this._isVoiceMode = !this._isVoiceMode;
if (this._isVoiceMode) { if (this._isVoiceMode) {
this._startVoiceRecognition(); this._startVoiceRecognition();
+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.30"; const src = "/openclaw/openclaw-chat-card.js?v=0.1.32";
console.info("OpenClaw loader importing", src); console.info("OpenClaw loader importing", src);
await import(src); await import(src);
} }