stt fixes

This commit is contained in:
techartdev
2026-02-20 21:46:24 +02:00
parent 9f226c2ea9
commit e5510cd6ab
6 changed files with 209 additions and 30 deletions
+16
View File
@@ -2,6 +2,22 @@
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.34] - 2026-02-20
### Changed
- Assist STT microphone capture now uses `AudioWorkletNode` when available, with automatic fallback to `ScriptProcessorNode` for older browsers.
- Reduced browser deprecation noise by avoiding `ScriptProcessorNode` on modern browser engines.
### Documentation
- Expanded README voice documentation with practical guidance for `voice_provider` (`browser` vs `assist_stt`) and provider-specific troubleshooting.
## [0.1.33] - 2026-02-20
### Fixed
- Reduced `415 Unsupported Media Type` failures for `assist_stt` by fetching STT provider capabilities and negotiating metadata before upload.
- Assist STT now auto-matches provider-supported language values (for example `bg` vs `bg-BG`) when submitting transcription audio.
- Assist STT now aligns upload metadata sample rate/channels with provider-supported values when available.
## [0.1.32] - 2026-02-20 ## [0.1.32] - 2026-02-20
### Added ### Added
+19 -2
View File
@@ -125,6 +125,20 @@ When enabled, OpenClaw tool-call responses can execute Home Assistant services.
- **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`) - **Voice input provider** (`browser` or `assist_stt`)
### Voice provider usage
- **`browser`**
- Uses browser Web Speech recognition.
- Supports manual mic and continuous voice mode (wake word flow).
- Best when browser STT is stable in your environment.
- **`assist_stt`**
- Uses Home Assistant STT provider via `/api/stt/<provider>`.
- Intended for manual mic input (press mic, speak, auto-stop, transcribe, send).
- Continuous voice mode is not used in this provider.
For `assist_stt`, make sure an STT engine is configured in **Settings → Voice assistants**.
--- ---
## Browser voice note (important) ## Browser voice note (important)
@@ -215,8 +229,11 @@ action:
### Voice button is active but no transcript is sent ### Voice button is active but no transcript is sent
- Check browser mic permission for your HA URL - Check browser mic permission for your HA URL
- Open browser console for `OpenClaw: Speech recognition error` - Confirm **Voice input provider** setting in integration options:
- If you see repeated `network`, this is usually browser speech backend failure - `browser` for Web Speech recognition
- `assist_stt` for Home Assistant STT transcription
- For `browser`: open browser console for `OpenClaw: Speech recognition error`; repeated `network` usually means browser speech backend failure
- For `assist_stt`: check network calls to `/api/stt/<provider>` and verify Home Assistant Voice/STT provider is configured
### Responses do not appear after sending ### Responses do not appear after sending
+1 -1
View File
@@ -78,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.32" _CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.34"
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.32", "version": "0.1.34",
"dependencies": ["conversation"], "dependencies": ["conversation"],
"after_dependencies": ["hassio", "lovelace"] "after_dependencies": ["hassio", "lovelace"]
} }
@@ -13,7 +13,7 @@
* + subscribes to openclaw_message_received events. * + subscribes to openclaw_message_received events.
*/ */
const CARD_VERSION = "0.3.0"; const CARD_VERSION = "0.3.2";
// 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;
@@ -80,6 +80,7 @@ class OpenClawChatCard extends HTMLElement {
this._assistAudioStream = null; this._assistAudioStream = null;
this._assistAudioContext = null; this._assistAudioContext = null;
this._assistAudioSource = null; this._assistAudioSource = null;
this._assistAudioWorkletNode = null;
this._assistAudioProcessor = null; this._assistAudioProcessor = null;
this._assistAudioSilenceGain = null; this._assistAudioSilenceGain = null;
this._assistAudioChunks = []; this._assistAudioChunks = [];
@@ -600,17 +601,68 @@ class OpenClawChatCard extends HTMLElement {
this._assistInputSampleRate = this._assistAudioContext.sampleRate || 16000; this._assistInputSampleRate = this._assistAudioContext.sampleRate || 16000;
this._assistAudioSource = this._assistAudioContext.createMediaStreamSource(this._assistAudioStream); this._assistAudioSource = this._assistAudioContext.createMediaStreamSource(this._assistAudioStream);
this._assistAudioProcessor = this._assistAudioContext.createScriptProcessor(4096, 1, 1);
this._assistAudioSilenceGain = this._assistAudioContext.createGain(); this._assistAudioSilenceGain = this._assistAudioContext.createGain();
this._assistAudioSilenceGain.gain.value = 0; this._assistAudioSilenceGain.gain.value = 0;
this._assistAudioProcessor.onaudioprocess = (event) => { let usingWorklet = false;
const input = event.inputBuffer.getChannelData(0); if (typeof AudioWorkletNode !== "undefined" && this._assistAudioContext.audioWorklet?.addModule) {
this._assistAudioChunks.push(new Float32Array(input)); try {
}; const workletSource = `
class OpenClawAssistCaptureProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
const channel = input && input[0];
if (channel && channel.length) {
this.port.postMessage(new Float32Array(channel));
}
return true;
}
}
registerProcessor("openclaw-assist-capture", OpenClawAssistCaptureProcessor);
`;
const blob = new Blob([workletSource], { type: "application/javascript" });
const moduleUrl = URL.createObjectURL(blob);
try {
await this._assistAudioContext.audioWorklet.addModule(moduleUrl);
} finally {
URL.revokeObjectURL(moduleUrl);
}
this._assistAudioWorkletNode = new AudioWorkletNode(
this._assistAudioContext,
"openclaw-assist-capture",
{
numberOfInputs: 1,
numberOfOutputs: 1,
channelCount: 1,
}
);
this._assistAudioWorkletNode.port.onmessage = (event) => {
const chunk = event.data;
if (chunk && chunk.length) {
this._assistAudioChunks.push(new Float32Array(chunk));
}
};
this._assistAudioSource.connect(this._assistAudioWorkletNode);
this._assistAudioWorkletNode.connect(this._assistAudioSilenceGain);
usingWorklet = true;
} catch (workletErr) {
console.debug("OpenClaw: AudioWorklet capture unavailable, falling back", workletErr);
this._assistAudioWorkletNode = null;
}
}
if (!usingWorklet) {
this._assistAudioProcessor = this._assistAudioContext.createScriptProcessor(4096, 1, 1);
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._assistAudioSource.connect(this._assistAudioProcessor);
this._assistAudioProcessor.connect(this._assistAudioSilenceGain);
this._assistAudioSilenceGain.connect(this._assistAudioContext.destination); this._assistAudioSilenceGain.connect(this._assistAudioContext.destination);
this._assistRecordingActive = true; this._assistRecordingActive = true;
@@ -644,6 +696,11 @@ class OpenClawChatCard extends HTMLElement {
this._assistAudioProcessor.onaudioprocess = null; this._assistAudioProcessor.onaudioprocess = null;
this._assistAudioProcessor = null; this._assistAudioProcessor = null;
} }
if (this._assistAudioWorkletNode) {
this._assistAudioWorkletNode.disconnect();
this._assistAudioWorkletNode.port.onmessage = null;
this._assistAudioWorkletNode = null;
}
if (this._assistAudioSource) { if (this._assistAudioSource) {
this._assistAudioSource.disconnect(); this._assistAudioSource.disconnect();
this._assistAudioSource = null; this._assistAudioSource = null;
@@ -687,10 +744,36 @@ class OpenClawChatCard extends HTMLElement {
try { try {
const language = this._getSpeechRecognitionLanguage(); const language = this._getSpeechRecognitionLanguage();
const wavBuffer = this._encodeWavFromChunks(this._assistAudioChunks, this._assistInputSampleRate); const token = this._hass?.auth?.data?.access_token;
let providerInfo = null;
try {
const providerResponse = await fetch(
`/api/stt/${encodeURIComponent(this._preferredAssistSttEngine)}`,
{
method: "GET",
headers: token ? { Authorization: `Bearer ${token}` } : {},
}
);
if (providerResponse.ok) {
providerInfo = await providerResponse.json();
}
} catch (providerErr) {
console.debug("OpenClaw: Assist STT provider info fetch failed:", providerErr);
}
const negotiatedLanguage = this._pickAssistSttLanguage(language, providerInfo?.languages);
const negotiatedSampleRate = this._pickAssistSttSampleRate(providerInfo?.sample_rates);
const negotiatedChannel = this._pickAssistSttChannel(providerInfo?.channels);
const wavBuffer = this._encodeWavFromChunks(
this._assistAudioChunks,
this._assistInputSampleRate,
negotiatedSampleRate,
negotiatedChannel
);
this._assistAudioChunks = []; this._assistAudioChunks = [];
const token = this._hass?.auth?.data?.access_token;
const response = await fetch( const response = await fetch(
`/api/stt/${encodeURIComponent(this._preferredAssistSttEngine)}`, `/api/stt/${encodeURIComponent(this._preferredAssistSttEngine)}`,
{ {
@@ -698,7 +781,7 @@ class OpenClawChatCard extends HTMLElement {
headers: { headers: {
"Content-Type": "audio/wav", "Content-Type": "audio/wav",
"X-Speech-Content": "X-Speech-Content":
`format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=1; language=${language}`, `format=wav; codec=pcm; sample_rate=${negotiatedSampleRate}; bit_rate=16; channel=${negotiatedChannel}; language=${negotiatedLanguage}`,
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: new Blob([wavBuffer], { type: "audio/wav" }), body: new Blob([wavBuffer], { type: "audio/wav" }),
@@ -755,7 +838,64 @@ class OpenClawChatCard extends HTMLElement {
return outputBuffer; return outputBuffer;
} }
_encodeWavFromChunks(chunks, inputSampleRate) { _pickAssistSttLanguage(preferredLanguage, supportedLanguages) {
if (!Array.isArray(supportedLanguages) || !supportedLanguages.length) {
return preferredLanguage;
}
const preferred = String(preferredLanguage || "").toLowerCase();
const supported = supportedLanguages.map((value) => String(value || "")).filter(Boolean);
const exact = supported.find((value) => value.toLowerCase() === preferred);
if (exact) return exact;
const preferredBase = preferred.split("-")[0];
if (preferredBase) {
const baseExact = supported.find((value) => value.toLowerCase() === preferredBase);
if (baseExact) return baseExact;
const prefix = supported.find((value) => value.toLowerCase().startsWith(`${preferredBase}-`));
if (prefix) return prefix;
}
return supported[0];
}
_pickAssistSttSampleRate(supportedSampleRates) {
if (!Array.isArray(supportedSampleRates) || !supportedSampleRates.length) {
return 16000;
}
const numericRates = supportedSampleRates
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0);
if (!numericRates.length) return 16000;
if (numericRates.includes(16000)) return 16000;
return numericRates[0];
}
_pickAssistSttChannel(supportedChannels) {
if (!Array.isArray(supportedChannels) || !supportedChannels.length) {
return 1;
}
const asNumbers = supportedChannels
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0);
if (asNumbers.length) {
if (asNumbers.includes(1)) return 1;
return asNumbers[0];
}
const normalized = supportedChannels.map((value) => String(value).toLowerCase());
if (normalized.includes("channel_mono")) return 1;
if (normalized.includes("channel_stereo")) return 2;
return 1;
}
_encodeWavFromChunks(chunks, inputSampleRate, outputSampleRate = 16000, channels = 1) {
let totalLength = 0; let totalLength = 0;
for (const chunk of chunks) { for (const chunk of chunks) {
totalLength += chunk.length; totalLength += chunk.length;
@@ -768,8 +908,13 @@ class OpenClawChatCard extends HTMLElement {
offset += chunk.length; offset += chunk.length;
} }
const downsampled = this._downsampleBuffer(merged, inputSampleRate, 16000); const downsampled = this._downsampleBuffer(merged, inputSampleRate, outputSampleRate);
const buffer = new ArrayBuffer(44 + downsampled.length * 2); const frameCount = downsampled.length;
const sampleCount = frameCount * channels;
const bytesPerSample = 2;
const blockAlign = channels * bytesPerSample;
const byteRate = outputSampleRate * blockAlign;
const buffer = new ArrayBuffer(44 + sampleCount * bytesPerSample);
const view = new DataView(buffer); const view = new DataView(buffer);
const writeString = (dataView, start, value) => { const writeString = (dataView, start, value) => {
@@ -779,24 +924,27 @@ class OpenClawChatCard extends HTMLElement {
}; };
writeString(view, 0, "RIFF"); writeString(view, 0, "RIFF");
view.setUint32(4, 36 + downsampled.length * 2, true); view.setUint32(4, 36 + sampleCount * bytesPerSample, true);
writeString(view, 8, "WAVE"); writeString(view, 8, "WAVE");
writeString(view, 12, "fmt "); writeString(view, 12, "fmt ");
view.setUint32(16, 16, true); view.setUint32(16, 16, true);
view.setUint16(20, 1, true); view.setUint16(20, 1, true);
view.setUint16(22, 1, true); view.setUint16(22, channels, true);
view.setUint32(24, 16000, true); view.setUint32(24, outputSampleRate, true);
view.setUint32(28, 16000 * 2, true); view.setUint32(28, byteRate, true);
view.setUint16(32, 2, true); view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true); view.setUint16(34, 16, true);
writeString(view, 36, "data"); writeString(view, 36, "data");
view.setUint32(40, downsampled.length * 2, true); view.setUint32(40, sampleCount * bytesPerSample, true);
let wavOffset = 44; let wavOffset = 44;
for (let idx = 0; idx < downsampled.length; idx += 1) { for (let idx = 0; idx < frameCount; idx += 1) {
const sample = Math.max(-1, Math.min(1, downsampled[idx])); const sample = Math.max(-1, Math.min(1, downsampled[idx]));
view.setInt16(wavOffset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true); const pcm = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
wavOffset += 2; for (let channelIndex = 0; channelIndex < channels; channelIndex += 1) {
view.setInt16(wavOffset, pcm, true);
wavOffset += 2;
}
} }
return buffer; return buffer;
@@ -817,7 +965,6 @@ class OpenClawChatCard extends HTMLElement {
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) { if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
console.warn("OpenClaw: Speech recognition not supported in this browser"); console.warn("OpenClaw: Speech recognition not supported in this browser");
this._voiceStatus = "Speech recognition not supported by this browser.";
this._render(); this._render();
return; return;
} }
@@ -825,7 +972,6 @@ class OpenClawChatCard extends HTMLElement {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
this._recognition = new SpeechRecognition(); this._recognition = new SpeechRecognition();
this._recognition.continuous = this._isVoiceMode; this._recognition.continuous = this._isVoiceMode;
this._recognition.interimResults = true;
this._recognition.lang = this._getSpeechRecognitionLanguage(); this._recognition.lang = this._getSpeechRecognitionLanguage();
this._voiceStatus = this._isVoiceMode this._voiceStatus = this._isVoiceMode
? `Listening (${this._recognition.lang}, wake word: ${this._wakeWord || "hey openclaw"})` ? `Listening (${this._recognition.lang}, wake word: ${this._wakeWord || "hey openclaw"})`
+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.32"; const src = "/openclaw/openclaw-chat-card.js?v=0.1.34";
console.info("OpenClaw loader importing", src); console.info("OpenClaw loader importing", src);
await import(src); await import(src);
} }