iobroker.assistant 1.4.4 → 1.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +10 -0
  2. package/admin/custom/.vite/manifest.json +1 -1
  3. package/admin/custom/assets/Components-BR57MZLJ.js +2 -0
  4. package/admin/custom/customComponents.js +1 -1
  5. package/admin/custom/i18n/de.json +3 -0
  6. package/admin/custom/i18n/en.json +3 -0
  7. package/admin/custom/i18n/es.json +3 -0
  8. package/admin/custom/i18n/fr.json +3 -0
  9. package/admin/custom/i18n/it.json +3 -0
  10. package/admin/custom/i18n/nl.json +3 -0
  11. package/admin/custom/i18n/pl.json +3 -0
  12. package/admin/custom/i18n/pt.json +3 -0
  13. package/admin/custom/i18n/ru.json +3 -0
  14. package/admin/custom/i18n/uk.json +3 -0
  15. package/admin/custom/i18n/zh-cn.json +3 -0
  16. package/admin/custom/index.html +1 -1
  17. package/admin/custom/mf-manifest.json +1 -1
  18. package/admin/custom/mf-stats.json +1 -1
  19. package/admin/i18n/de.json +6 -2
  20. package/admin/i18n/en.json +6 -2
  21. package/admin/i18n/es.json +6 -2
  22. package/admin/i18n/fr.json +6 -2
  23. package/admin/i18n/it.json +6 -2
  24. package/admin/i18n/nl.json +6 -2
  25. package/admin/i18n/pl.json +6 -2
  26. package/admin/i18n/pt.json +6 -2
  27. package/admin/i18n/ru.json +6 -2
  28. package/admin/i18n/uk.json +6 -2
  29. package/admin/i18n/zh-cn.json +6 -2
  30. package/admin/jsonConfig.json +24 -2
  31. package/build/lib/voice/engines.js +3 -0
  32. package/build/lib/voice/engines.js.map +1 -1
  33. package/build/lib/voice/esphome.js +160 -19
  34. package/build/lib/voice/esphome.js.map +1 -1
  35. package/build/lib/voice/esphomeEntities.js +76 -6
  36. package/build/lib/voice/esphomeEntities.js.map +1 -1
  37. package/build/lib/voice/esphomeProto.js.map +1 -1
  38. package/build/lib/voice/flac.js +162 -0
  39. package/build/lib/voice/flac.js.map +1 -0
  40. package/build/lib/voice/mediaServer.js +8 -1
  41. package/build/lib/voice/mediaServer.js.map +1 -1
  42. package/build/lib/voice/replyFormat.js +158 -0
  43. package/build/lib/voice/replyFormat.js.map +1 -0
  44. package/build/lib/voice/stt.js +37 -6
  45. package/build/lib/voice/stt.js.map +1 -1
  46. package/build/main.js +165 -4
  47. package/build/main.js.map +1 -1
  48. package/io-package.json +33 -29
  49. package/package.json +1 -1
  50. package/admin/custom/assets/Components-CCl7INIF.js +0 -2
@@ -58,7 +58,7 @@
58
58
  "ESPHome satellites": "Satelity ESPHome",
59
59
  "ESPHome satellites do not get the reply over their API socket — they fetch it as a URL. This is the port that small HTTP server listens on.": "Satelity ESPHome nie odbierają odpowiedzi przez swoje gniazdo API — pobierają ją jako URL. To port, na którym nasłuchuje ten mały serwer HTTP.",
60
60
  "Empty = tts-1. Also: tts-1-hd, gpt-4o-mini-tts.": "Pusty = tts-1. Również: tts-1-hd, gpt-4o-mini-tts.",
61
- "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe.": "Pusty = szept-1. Również: gpt-4o-transcribe, gpt-4o-mini-transcribe.",
61
+ "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe. With an own server: the model as that server names it, e.g. Systran/faster-whisper-large-v3-turbo.": "Puste = whisper-1. Również: gpt-4o-transcribe, gpt-4o-mini-transcribe. Z własnym serwerem: model tak, jak nazywa go serwer, np. Systran/faster-whisper-large-v3-turbo.",
62
62
  "Enable voice (STT/TTS for satellites)": "Włącz głos (STT/TTS dla satelitów)",
63
63
  "Enable voice server (satellites)": "Włącz serwer głosowy (satelity)",
64
64
  "Enables speech recognition/synthesis. ioBroker-native satellites reach it over the message bus — no port needed. Turn on the UDP server below only for ESP / Hannah satellites.": "Umożliwia rozpoznawanie/syntezę mowy. Satelity natywne dla ioBroker osiągają go przez magistralę komunikatów — nie jest potrzebny żaden port. Włącz serwer UDP poniżej tylko dla satelitów ESP/Hannah.",
@@ -222,5 +222,9 @@
222
222
  "Person": "Osoba",
223
223
  "Guest": "Gość",
224
224
  "Pet": "Zwierzę",
225
- "Group": "Grupa"
225
+ "Group": "Grupa",
226
+ "Speech recognition server (optional)": "Serwer rozpoznawania mowy (opcjonalnie)",
227
+ "OpenAI-compatible server instead of OpenAI, e.g. a local Whisper: http://192.168.1.10:8000/v1 (speaches, faster-whisper-server, LocalAI). Empty = OpenAI.": "Serwer zgodny z OpenAI zamiast OpenAI, np. lokalny Whisper: http://192.168.1.10:8000/v1 (speaches, faster-whisper-server, LocalAI). Puste = OpenAI.",
228
+ "Key of the speech recognition server": "Klucz serwera rozpoznawania mowy",
229
+ "Only if the server wants one. The OpenAI key is never sent to it.": "Tylko jeśli serwer go wymaga. Klucz OpenAI nigdy nie jest tam wysyłany."
226
230
  }
@@ -58,7 +58,7 @@
58
58
  "ESPHome satellites": "Satélites ESPHome",
59
59
  "ESPHome satellites do not get the reply over their API socket — they fetch it as a URL. This is the port that small HTTP server listens on.": "Os satélites ESPHome não recebem a resposta pelo seu socket de API — buscam-na como URL. Esta é a porta em que esse pequeno servidor HTTP escuta.",
60
60
  "Empty = tts-1. Also: tts-1-hd, gpt-4o-mini-tts.": "Vazio = tts-1. Também: tts-1-hd, gpt-4o-mini-tts.",
61
- "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe.": "Vazio = whisper-1. Também: gpt-4o-transcribe, gpt-4o-mini-transcribe.",
61
+ "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe. With an own server: the model as that server names it, e.g. Systran/faster-whisper-large-v3-turbo.": "Vazio = whisper-1. Também: gpt-4o-transcribe, gpt-4o-mini-transcribe. Com servidor próprio: o modelo como o servidor o chama, p. ex. Systran/faster-whisper-large-v3-turbo.",
62
62
  "Enable voice (STT/TTS for satellites)": "Ativar voz (STT/TTS para satélites)",
63
63
  "Enable voice server (satellites)": "Ativar servidor de voz (satélites)",
64
64
  "Enables speech recognition/synthesis. ioBroker-native satellites reach it over the message bus — no port needed. Turn on the UDP server below only for ESP / Hannah satellites.": "Habilita o reconhecimento/síntese de voz. Os satélites nativos do ioBroker acessam o servidor através do barramento de mensagens — sem necessidade de porta. Ative o servidor UDP abaixo apenas para satélites ESP/Hannah.",
@@ -222,5 +222,9 @@
222
222
  "Person": "Pessoa",
223
223
  "Guest": "Convidado",
224
224
  "Pet": "Animal de estimação",
225
- "Group": "Grupo"
225
+ "Group": "Grupo",
226
+ "Speech recognition server (optional)": "Servidor de reconhecimento de voz (opcional)",
227
+ "OpenAI-compatible server instead of OpenAI, e.g. a local Whisper: http://192.168.1.10:8000/v1 (speaches, faster-whisper-server, LocalAI). Empty = OpenAI.": "Servidor compatível com OpenAI em vez da OpenAI, p. ex. um Whisper local: http://192.168.1.10:8000/v1 (speaches, faster-whisper-server, LocalAI). Vazio = OpenAI.",
228
+ "Key of the speech recognition server": "Chave do servidor de reconhecimento de voz",
229
+ "Only if the server wants one. The OpenAI key is never sent to it.": "Só se o servidor pedir uma. A chave da OpenAI nunca é enviada para lá."
226
230
  }
@@ -58,7 +58,7 @@
58
58
  "ESPHome satellites": "Сателлиты ESPHome",
59
59
  "ESPHome satellites do not get the reply over their API socket — they fetch it as a URL. This is the port that small HTTP server listens on.": "Сателлиты ESPHome получают ответ не через свой сокет API — они забирают его по URL. Это порт, на котором слушает этот небольшой HTTP-сервер.",
60
60
  "Empty = tts-1. Also: tts-1-hd, gpt-4o-mini-tts.": "Пусто = tts-1. Также: tts-1-hd, gpt-4o-mini-tts.",
61
- "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe.": "Пусто = whisper-1. Также: gpt-4o-transcribe, gpt-4o-mini-transcribe.",
61
+ "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe. With an own server: the model as that server names it, e.g. Systran/faster-whisper-large-v3-turbo.": "Пусто = whisper-1. Также: gpt-4o-transcribe, gpt-4o-mini-transcribe. Со своим сервером: модель так, как её называет сервер, напр. Systran/faster-whisper-large-v3-turbo.",
62
62
  "Enable voice (STT/TTS for satellites)": "Включить голосовую связь (STT/TTS для спутниковой связи)",
63
63
  "Enable voice server (satellites)": "Включить голосовой сервер (спутники)",
64
64
  "Enables speech recognition/synthesis. ioBroker-native satellites reach it over the message bus — no port needed. Turn on the UDP server below only for ESP / Hannah satellites.": "Включает распознавание/синтез речи. Спутники, использующие ioBroker, получают к нему доступ через шину сообщений — порт не требуется. Включите UDP-сервер, указанный ниже, только для спутников ESP/Hannah.",
@@ -222,5 +222,9 @@
222
222
  "Person": "Человек",
223
223
  "Guest": "Гость",
224
224
  "Pet": "Питомец",
225
- "Group": "Группа"
225
+ "Group": "Группа",
226
+ "Speech recognition server (optional)": "Сервер распознавания речи (опционально)",
227
+ "OpenAI-compatible server instead of OpenAI, e.g. a local Whisper: http://192.168.1.10:8000/v1 (speaches, faster-whisper-server, LocalAI). Empty = OpenAI.": "OpenAI-совместимый сервер вместо OpenAI, напр. локальный Whisper: http://192.168.1.10:8000/v1 (speaches, faster-whisper-server, LocalAI). Пусто = OpenAI.",
228
+ "Key of the speech recognition server": "Ключ сервера распознавания речи",
229
+ "Only if the server wants one. The OpenAI key is never sent to it.": "Только если сервер его требует. Ключ OpenAI туда никогда не отправляется."
226
230
  }
@@ -58,7 +58,7 @@
58
58
  "ESPHome satellites": "Сателіти ESPHome",
59
59
  "ESPHome satellites do not get the reply over their API socket — they fetch it as a URL. This is the port that small HTTP server listens on.": "Сателіти ESPHome отримують відповідь не через свій сокет API — вони забирають її за URL. Це порт, на якому слухає цей невеликий HTTP-сервер.",
60
60
  "Empty = tts-1. Also: tts-1-hd, gpt-4o-mini-tts.": "Порожнє = tts-1. Також: tts-1-hd, gpt-4o-mini-tts.",
61
- "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe.": "Порожнє = шепіт-1. Також: gpt-4o-trancribe, gpt-4o-mini-trancribe.",
61
+ "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe. With an own server: the model as that server names it, e.g. Systran/faster-whisper-large-v3-turbo.": "Порожньо = whisper-1. Також: gpt-4o-transcribe, gpt-4o-mini-transcribe. З власним сервером: модель так, як її називає сервер, напр. Systran/faster-whisper-large-v3-turbo.",
62
62
  "Enable voice (STT/TTS for satellites)": "Увімкнути голосовий зв'язок (STT/TTS для супутників)",
63
63
  "Enable voice server (satellites)": "Увімкнути голосовий сервер (супутники)",
64
64
  "Enables speech recognition/synthesis. ioBroker-native satellites reach it over the message bus — no port needed. Turn on the UDP server below only for ESP / Hannah satellites.": "Дозволяє розпізнавання/синтез мовлення. Супутники, сумісні з ioBroker, досягають його через шину повідомлень — порт не потрібен. Увімкніть UDP-сервер нижче лише для супутників ESP / Hannah.",
@@ -222,5 +222,9 @@
222
222
  "Person": "Людина",
223
223
  "Guest": "Гість",
224
224
  "Pet": "Улюбленець",
225
- "Group": "Група"
225
+ "Group": "Група",
226
+ "Speech recognition server (optional)": "Сервер розпізнавання мовлення (необов'язково)",
227
+ "OpenAI-compatible server instead of OpenAI, e.g. a local Whisper: http://192.168.1.10:8000/v1 (speaches, faster-whisper-server, LocalAI). Empty = OpenAI.": "OpenAI-сумісний сервер замість OpenAI, напр. локальний Whisper: http://192.168.1.10:8000/v1 (speaches, faster-whisper-server, LocalAI). Порожньо = OpenAI.",
228
+ "Key of the speech recognition server": "Ключ сервера розпізнавання мовлення",
229
+ "Only if the server wants one. The OpenAI key is never sent to it.": "Лише якщо сервер його вимагає. Ключ OpenAI туди ніколи не надсилається."
226
230
  }
@@ -58,7 +58,7 @@
58
58
  "ESPHome satellites": "ESPHome 卫星",
59
59
  "ESPHome satellites do not get the reply over their API socket — they fetch it as a URL. This is the port that small HTTP server listens on.": "ESPHome 卫星不会通过其 API 套接字接收回复,而是按 URL 获取。这里就是那个小型 HTTP 服务器监听的端口。",
60
60
  "Empty = tts-1. Also: tts-1-hd, gpt-4o-mini-tts.": "空 = tts-1。另见:tts-1-hd、gpt-4o-mini-tts。",
61
- "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe.": "空 = whisper-1。另见:gpt-4o-transcribe、gpt-4o-mini-transcribe。",
61
+ "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe. With an own server: the model as that server names it, e.g. Systran/faster-whisper-large-v3-turbo.": "空 = whisper-1。另见:gpt-4o-transcribe、gpt-4o-mini-transcribe。使用自己的服务器时:按服务器的名称填写模型,例如 Systran/faster-whisper-large-v3-turbo。",
62
62
  "Enable voice (STT/TTS for satellites)": "启用语音功能(卫星语音转语音/文本转语音)",
63
63
  "Enable voice server (satellites)": "启用语音服务器(卫星)",
64
64
  "Enables speech recognition/synthesis. ioBroker-native satellites reach it over the message bus — no port needed. Turn on the UDP server below only for ESP / Hannah satellites.": "启用语音识别/合成功能。ioBroker 原生卫星通过消息总线与其通信,无需端口。仅当 ESP/Hannah 卫星连接时,才需要启用下方的 UDP 服务器。",
@@ -222,5 +222,9 @@
222
222
  "Person": "人员",
223
223
  "Guest": "访客",
224
224
  "Pet": "宠物",
225
- "Group": "组"
225
+ "Group": "组",
226
+ "Speech recognition server (optional)": "语音识别服务器(可选)",
227
+ "OpenAI-compatible server instead of OpenAI, e.g. a local Whisper: http://192.168.1.10:8000/v1 (speaches, faster-whisper-server, LocalAI). Empty = OpenAI.": "代替 OpenAI 的 OpenAI 兼容服务器,例如本地 Whisper:http://192.168.1.10:8000/v1(speaches、faster-whisper-server、LocalAI)。空 = OpenAI。",
228
+ "Key of the speech recognition server": "语音识别服务器的密钥",
229
+ "Only if the server wants one. The OpenAI key is never sent to it.": "仅当服务器需要时填写。OpenAI 密钥永远不会发送到该服务器。"
226
230
  }
@@ -1184,8 +1184,30 @@
1184
1184
  "newLine": true,
1185
1185
  "type": "text",
1186
1186
  "label": "OpenAI STT model (optional)",
1187
- "help": "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe.",
1188
- "hidden": "!data.voiceEnabled || data.sttProvider !== 'openai'",
1187
+ "help": "Empty = whisper-1. Also: gpt-4o-transcribe, gpt-4o-mini-transcribe. With an own server: the model as that server names it, e.g. Systran/faster-whisper-large-v3-turbo.",
1188
+ "hidden": "!data.voiceEnabled || (data.sttProvider !== 'openai' && data.sttFallback !== 'openai')",
1189
+ "xs": 12,
1190
+ "sm": 6,
1191
+ "md": 4,
1192
+ "lg": 4,
1193
+ "xl": 4
1194
+ },
1195
+ "sttBaseUrl": {
1196
+ "type": "text",
1197
+ "label": "Speech recognition server (optional)",
1198
+ "help": "OpenAI-compatible server instead of OpenAI, e.g. a local Whisper: http://192.168.1.10:8000/v1 (speaches, faster-whisper-server, LocalAI). Empty = OpenAI.",
1199
+ "hidden": "!data.voiceEnabled || (data.sttProvider !== 'openai' && data.sttFallback !== 'openai')",
1200
+ "xs": 12,
1201
+ "sm": 6,
1202
+ "md": 4,
1203
+ "lg": 4,
1204
+ "xl": 4
1205
+ },
1206
+ "sttServerKey": {
1207
+ "type": "password",
1208
+ "label": "Key of the speech recognition server",
1209
+ "help": "Only if the server wants one. The OpenAI key is never sent to it.",
1210
+ "hidden": "!data.voiceEnabled || (data.sttProvider !== 'openai' && data.sttFallback !== 'openai') || !data.sttBaseUrl",
1189
1211
  "xs": 12,
1190
1212
  "sm": 6,
1191
1213
  "md": 4,
@@ -104,6 +104,9 @@ function buildSttEngine(provider, ctx) {
104
104
  case 'piper':
105
105
  throw new Error('Piper is a text-to-speech engine, not speech-to-text');
106
106
  default:
107
+ if (ctx.sttBaseUrl?.trim()) {
108
+ return new stt_1.OpenAiStt(ctx.sttServerKey || '', ctx.sttModel || undefined, ctx.sttBaseUrl);
109
+ }
107
110
  requireOpenAi(ctx.creds);
108
111
  return new stt_1.OpenAiStt(ctx.creds.openaiKey, ctx.sttModel || undefined);
109
112
  }
@@ -1 +1 @@
1
- {"version":3,"file":"engines.js","sourceRoot":"","sources":["../../../src/lib/voice/engines.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Factory that builds the configured STT/TTS engine. Cloud engines (OpenAI/Azure/AWS) use resolved\n * credentials; local engines (Vosk/Piper) download their models on demand into the data dir.\n * Adding a provider = one new engine class + one `case` here.\n */\nimport { OpenAiStt, type SttEngine } from './stt';\nimport { OpenAiTts, type OpenAiVoice, type TtsEngine } from './tts';\nimport { AzureStt, AzureTts, listAzureVoices } from './azure';\nimport { AwsStt, AwsTts, listPollyVoices, type AwsCreds } from './aws';\nimport { VoskStt, listVoskModels } from './vosk';\nimport { PiperTts, listPiperVoices } from './piper';\nimport {\n OpenRouterStt,\n OpenRouterTts,\n listOpenRouterSpeechModels,\n voicesForLanguage,\n DEFAULT_OPENROUTER_TTS_MODEL,\n type AudioDecoder,\n} from './openrouter';\nimport * as path from 'node:path';\n\nimport { CachedTts } from './ttsCache';\nimport { FallbackStt, FallbackTts, type NamedEngine } from './fallback';\nimport { isoToLocale } from './lang';\nimport type { VoiceLogger } from './download';\n\nexport type SpeechProvider = 'openai' | 'openrouter' | 'azure' | 'aws' | 'vosk' | 'piper';\n\n/** All speech credentials, resolved from config (or the central credential store). */\nexport interface VoiceCredentials {\n openaiKey: string;\n openrouterKey: string;\n azureKey: string;\n azureRegion: string;\n aws: AwsCreds;\n}\n\n/** Provider-specific voice ids (each provider names voices differently). */\nexport interface VoiceNames {\n openai: string; // alloy/echo/…\n azure: string; // e.g. de-DE-KatjaNeural\n aws: string; // e.g. Vicki\n piper: string; // e.g. de_DE-thorsten-medium\n openrouter: string; // depends on the model, e.g. Kore, george\n}\n\n/** Everything the factory needs to build any engine. */\nexport interface EngineContext {\n creds: VoiceCredentials;\n voices: VoiceNames;\n /** Optional second engine, used when the primary one fails ('' = none). */\n sttFallback?: SpeechProvider | '';\n ttsFallback?: SpeechProvider | '';\n /** Writable instance data dir (for the local engines' downloads). */\n dataDir: string;\n log: VoiceLogger;\n /** Local model overrides ('' → default per language). */\n voskModel: string;\n /** OpenAI STT model ('' → 'whisper-1'; e.g. 'gpt-4o-transcribe'). */\n sttModel: string;\n /** OpenAI TTS model ('' → 'tts-1'; e.g. 'gpt-4o-mini-tts'). */\n ttsModel: string;\n /** OpenRouter models ('' → the defaults of openrouter.ts) */\n openrouterSttModel: string;\n openrouterTtsModel: string;\n /** Decoder for compressed speech (OpenRouter models that only return mp3); absent → those models fail */\n decodeAudio?: AudioDecoder;\n /** Longest text that is spoken, in characters; longer ones are cut at a sentence end. 0 = no limit, absent = 400. */\n ttsMaxChars?: number;\n}\n\nfunction requireAzure(creds: VoiceCredentials): void {\n if (!creds.azureKey || !creds.azureRegion) {\n throw new Error('Azure speech needs a subscription key and region');\n }\n}\n\nfunction requireAws(creds: VoiceCredentials): void {\n if (!creds.aws.accessKeyId || !creds.aws.secretAccessKey || !creds.aws.region) {\n throw new Error('AWS speech needs an access key id, secret access key and region');\n }\n}\n\nfunction requireOpenAi(creds: VoiceCredentials): void {\n if (!creds.openaiKey) {\n throw new Error('OpenAI speech needs an API key');\n }\n}\n\nfunction requireOpenRouter(creds: VoiceCredentials): void {\n if (!creds.openrouterKey) {\n throw new Error('OpenRouter speech needs an API key');\n }\n}\n\nexport function createSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n const primary: NamedEngine<SttEngine> = { name: provider, engine: buildSttEngine(provider, ctx) };\n const fallback = ctx.sttFallback && ctx.sttFallback !== provider ? ctx.sttFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackStt([primary, { name: fallback, engine: buildSttEngine(fallback, ctx) }], ctx.log);\n } catch (e) {\n ctx.log.warn(`Speech-to-text fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\nfunction buildSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureStt(ctx.creds.azureKey, ctx.creds.azureRegion);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsStt(ctx.creds.aws);\n case 'vosk':\n return new VoskStt(ctx.dataDir, ctx.voskModel, ctx.log);\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterStt(ctx.creds.openrouterKey, ctx.openrouterSttModel);\n case 'piper':\n throw new Error('Piper is a text-to-speech engine, not speech-to-text');\n default:\n requireOpenAi(ctx.creds);\n return new OpenAiStt(ctx.creds.openaiKey, ctx.sttModel || undefined);\n }\n}\n\nexport function createTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n // Each engine gets the shared behaviour (disk cache, length limit, SSML) and its own cache directory,\n // so a fallback engine's voice can never be served later while the primary one is healthy again.\n const primary: NamedEngine<TtsEngine> = { name: provider, engine: cachedTts(provider, ctx) };\n const fallback = ctx.ttsFallback && ctx.ttsFallback !== provider ? ctx.ttsFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackTts([primary, { name: fallback, engine: cachedTts(fallback, ctx) }], ctx.log);\n } catch (e) {\n // A fallback that cannot even be built (missing key, wrong provider) must not take the main one down.\n ctx.log.warn(`Text-to-speech fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\n/**\n * One provider binding plus the shared cache/length/SSML layer. One cache directory per provider+voice —\n * a voice change must not serve audio in the old voice.\n */\nfunction cachedTts(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n const voice =\n provider === 'azure'\n ? ctx.voices.azure\n : provider === 'aws'\n ? ctx.voices.aws\n : provider === 'piper'\n ? ctx.voices.piper\n : provider === 'openrouter'\n ? `${ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL}-${ctx.voices.openrouter}`\n : ctx.voices.openai;\n const dir = path.join(ctx.dataDir, 'ttscache', `${provider}-${(voice || 'default').replace(/[^\\w.-]/g, '_')}`);\n return new CachedTts(buildTtsEngine(provider, ctx), { dir, log: ctx.log, maxChars: ctx.ttsMaxChars });\n}\n\nfunction buildTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureTts(ctx.creds.azureKey, ctx.creds.azureRegion, ctx.voices.azure);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsTts(ctx.creds.aws, ctx.voices.aws);\n case 'piper':\n return new PiperTts(ctx.dataDir, ctx.voices.piper, ctx.log);\n case 'vosk':\n throw new Error('Vosk is a speech-to-text engine, not text-to-speech');\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterTts(\n ctx.creds.openrouterKey,\n ctx.openrouterTtsModel,\n ctx.voices.openrouter,\n ctx.decodeAudio,\n );\n default:\n requireOpenAi(ctx.creds);\n return new OpenAiTts(\n ctx.creds.openaiKey,\n (ctx.voices.openai || 'alloy') as OpenAiVoice,\n ctx.ttsModel || undefined,\n );\n }\n}\n\n/** OpenAI TTS voices are a fixed set (no list API). */\nconst OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];\n\n/** List the available TTS voices for a provider (for the settings voice dropdown). */\nexport async function listVoices(provider: SpeechProvider, ctx: EngineContext, language: string): Promise<string[]> {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return listAzureVoices(ctx.creds.azureKey, ctx.creds.azureRegion, isoToLocale(language));\n case 'aws':\n requireAws(ctx.creds);\n return listPollyVoices(ctx.creds.aws, isoToLocale(language));\n case 'piper':\n return listPiperVoices();\n case 'openrouter': {\n // public list, no key needed: the voices of the chosen model\n const model = ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL;\n const models = await listOpenRouterSpeechModels('speech');\n return voicesForLanguage(models.find(m => m.id === model)?.voices || [], language);\n }\n default:\n return OPENAI_VOICES;\n }\n}\n\n/** Suggested STT model names for the settings dropdown: the local Vosk catalogue, or the models of OpenRouter. */\nexport async function listSttModels(provider: SpeechProvider, language: string): Promise<string[]> {\n if (provider === 'openrouter') {\n return (await listOpenRouterSpeechModels('transcription')).map(m => m.id);\n }\n return provider === 'vosk' ? listVoskModels(language) : [];\n}\n\n/** TTS model names for the settings dropdown — only OpenRouter has a choice worth listing. */\nexport async function listTtsModels(provider: SpeechProvider): Promise<string[]> {\n return provider === 'openrouter' ? (await listOpenRouterSpeechModels('speech')).map(m => m.id) : [];\n}\n"]}
1
+ {"version":3,"file":"engines.js","sourceRoot":"","sources":["../../../src/lib/voice/engines.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Factory that builds the configured STT/TTS engine. Cloud engines (OpenAI/Azure/AWS) use resolved\n * credentials; local engines (Vosk/Piper) download their models on demand into the data dir.\n * Adding a provider = one new engine class + one `case` here.\n */\nimport { OpenAiStt, type SttEngine } from './stt';\nimport { OpenAiTts, type OpenAiVoice, type TtsEngine } from './tts';\nimport { AzureStt, AzureTts, listAzureVoices } from './azure';\nimport { AwsStt, AwsTts, listPollyVoices, type AwsCreds } from './aws';\nimport { VoskStt, listVoskModels } from './vosk';\nimport { PiperTts, listPiperVoices } from './piper';\nimport {\n OpenRouterStt,\n OpenRouterTts,\n listOpenRouterSpeechModels,\n voicesForLanguage,\n DEFAULT_OPENROUTER_TTS_MODEL,\n type AudioDecoder,\n} from './openrouter';\nimport * as path from 'node:path';\n\nimport { CachedTts } from './ttsCache';\nimport { FallbackStt, FallbackTts, type NamedEngine } from './fallback';\nimport { isoToLocale } from './lang';\nimport type { VoiceLogger } from './download';\n\nexport type SpeechProvider = 'openai' | 'openrouter' | 'azure' | 'aws' | 'vosk' | 'piper';\n\n/** All speech credentials, resolved from config (or the central credential store). */\nexport interface VoiceCredentials {\n openaiKey: string;\n openrouterKey: string;\n azureKey: string;\n azureRegion: string;\n aws: AwsCreds;\n}\n\n/** Provider-specific voice ids (each provider names voices differently). */\nexport interface VoiceNames {\n openai: string; // alloy/echo/…\n azure: string; // e.g. de-DE-KatjaNeural\n aws: string; // e.g. Vicki\n piper: string; // e.g. de_DE-thorsten-medium\n openrouter: string; // depends on the model, e.g. Kore, george\n}\n\n/** Everything the factory needs to build any engine. */\nexport interface EngineContext {\n creds: VoiceCredentials;\n voices: VoiceNames;\n /** Optional second engine, used when the primary one fails ('' = none). */\n sttFallback?: SpeechProvider | '';\n ttsFallback?: SpeechProvider | '';\n /** Writable instance data dir (for the local engines' downloads). */\n dataDir: string;\n log: VoiceLogger;\n /** Local model overrides ('' → default per language). */\n voskModel: string;\n /** OpenAI STT model ('' → 'whisper-1'; e.g. 'gpt-4o-transcribe'). */\n sttModel: string;\n /**\n * OpenAI-compatible transcription server instead of OpenAI ('' = OpenAI), e.g. a local whisper container.\n * It gets `sttServerKey`, never the OpenAI key — that one is only for OpenAI.\n */\n sttBaseUrl?: string;\n /** Key for `sttBaseUrl` ('' = the server needs none). */\n sttServerKey?: string;\n /** OpenAI TTS model ('' → 'tts-1'; e.g. 'gpt-4o-mini-tts'). */\n ttsModel: string;\n /** OpenRouter models ('' → the defaults of openrouter.ts) */\n openrouterSttModel: string;\n openrouterTtsModel: string;\n /** Decoder for compressed speech (OpenRouter models that only return mp3); absent → those models fail */\n decodeAudio?: AudioDecoder;\n /** Longest text that is spoken, in characters; longer ones are cut at a sentence end. 0 = no limit, absent = 400. */\n ttsMaxChars?: number;\n}\n\nfunction requireAzure(creds: VoiceCredentials): void {\n if (!creds.azureKey || !creds.azureRegion) {\n throw new Error('Azure speech needs a subscription key and region');\n }\n}\n\nfunction requireAws(creds: VoiceCredentials): void {\n if (!creds.aws.accessKeyId || !creds.aws.secretAccessKey || !creds.aws.region) {\n throw new Error('AWS speech needs an access key id, secret access key and region');\n }\n}\n\nfunction requireOpenAi(creds: VoiceCredentials): void {\n if (!creds.openaiKey) {\n throw new Error('OpenAI speech needs an API key');\n }\n}\n\nfunction requireOpenRouter(creds: VoiceCredentials): void {\n if (!creds.openrouterKey) {\n throw new Error('OpenRouter speech needs an API key');\n }\n}\n\nexport function createSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n const primary: NamedEngine<SttEngine> = { name: provider, engine: buildSttEngine(provider, ctx) };\n const fallback = ctx.sttFallback && ctx.sttFallback !== provider ? ctx.sttFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackStt([primary, { name: fallback, engine: buildSttEngine(fallback, ctx) }], ctx.log);\n } catch (e) {\n ctx.log.warn(`Speech-to-text fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\nfunction buildSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureStt(ctx.creds.azureKey, ctx.creds.azureRegion);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsStt(ctx.creds.aws);\n case 'vosk':\n return new VoskStt(ctx.dataDir, ctx.voskModel, ctx.log);\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterStt(ctx.creds.openrouterKey, ctx.openrouterSttModel);\n case 'piper':\n throw new Error('Piper is a text-to-speech engine, not speech-to-text');\n default:\n if (ctx.sttBaseUrl?.trim()) {\n return new OpenAiStt(ctx.sttServerKey || '', ctx.sttModel || undefined, ctx.sttBaseUrl);\n }\n requireOpenAi(ctx.creds);\n return new OpenAiStt(ctx.creds.openaiKey, ctx.sttModel || undefined);\n }\n}\n\nexport function createTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n // Each engine gets the shared behaviour (disk cache, length limit, SSML) and its own cache directory,\n // so a fallback engine's voice can never be served later while the primary one is healthy again.\n const primary: NamedEngine<TtsEngine> = { name: provider, engine: cachedTts(provider, ctx) };\n const fallback = ctx.ttsFallback && ctx.ttsFallback !== provider ? ctx.ttsFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackTts([primary, { name: fallback, engine: cachedTts(fallback, ctx) }], ctx.log);\n } catch (e) {\n // A fallback that cannot even be built (missing key, wrong provider) must not take the main one down.\n ctx.log.warn(`Text-to-speech fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\n/**\n * One provider binding plus the shared cache/length/SSML layer. One cache directory per provider+voice —\n * a voice change must not serve audio in the old voice.\n */\nfunction cachedTts(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n const voice =\n provider === 'azure'\n ? ctx.voices.azure\n : provider === 'aws'\n ? ctx.voices.aws\n : provider === 'piper'\n ? ctx.voices.piper\n : provider === 'openrouter'\n ? `${ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL}-${ctx.voices.openrouter}`\n : ctx.voices.openai;\n const dir = path.join(ctx.dataDir, 'ttscache', `${provider}-${(voice || 'default').replace(/[^\\w.-]/g, '_')}`);\n return new CachedTts(buildTtsEngine(provider, ctx), { dir, log: ctx.log, maxChars: ctx.ttsMaxChars });\n}\n\nfunction buildTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureTts(ctx.creds.azureKey, ctx.creds.azureRegion, ctx.voices.azure);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsTts(ctx.creds.aws, ctx.voices.aws);\n case 'piper':\n return new PiperTts(ctx.dataDir, ctx.voices.piper, ctx.log);\n case 'vosk':\n throw new Error('Vosk is a speech-to-text engine, not text-to-speech');\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterTts(\n ctx.creds.openrouterKey,\n ctx.openrouterTtsModel,\n ctx.voices.openrouter,\n ctx.decodeAudio,\n );\n default:\n requireOpenAi(ctx.creds);\n return new OpenAiTts(\n ctx.creds.openaiKey,\n (ctx.voices.openai || 'alloy') as OpenAiVoice,\n ctx.ttsModel || undefined,\n );\n }\n}\n\n/** OpenAI TTS voices are a fixed set (no list API). */\nconst OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];\n\n/** List the available TTS voices for a provider (for the settings voice dropdown). */\nexport async function listVoices(provider: SpeechProvider, ctx: EngineContext, language: string): Promise<string[]> {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return listAzureVoices(ctx.creds.azureKey, ctx.creds.azureRegion, isoToLocale(language));\n case 'aws':\n requireAws(ctx.creds);\n return listPollyVoices(ctx.creds.aws, isoToLocale(language));\n case 'piper':\n return listPiperVoices();\n case 'openrouter': {\n // public list, no key needed: the voices of the chosen model\n const model = ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL;\n const models = await listOpenRouterSpeechModels('speech');\n return voicesForLanguage(models.find(m => m.id === model)?.voices || [], language);\n }\n default:\n return OPENAI_VOICES;\n }\n}\n\n/** Suggested STT model names for the settings dropdown: the local Vosk catalogue, or the models of OpenRouter. */\nexport async function listSttModels(provider: SpeechProvider, language: string): Promise<string[]> {\n if (provider === 'openrouter') {\n return (await listOpenRouterSpeechModels('transcription')).map(m => m.id);\n }\n return provider === 'vosk' ? listVoskModels(language) : [];\n}\n\n/** TTS model names for the settings dropdown — only OpenRouter has a choice worth listing. */\nexport async function listTtsModels(provider: SpeechProvider): Promise<string[]> {\n return provider === 'openrouter' ? (await listOpenRouterSpeechModels('speech')).map(m => m.id) : [];\n}\n"]}
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.EsphomeSatellites = exports.TIMER_EVENT = exports.DEFAULT_SPEECH_LEVEL = void 0;
37
37
  exports.speechLevelOf = speechLevelOf;
38
+ exports.esphomeAddress = esphomeAddress;
38
39
  /**
39
40
  * ESPHome voice satellites — the fourth transport, next to the UDP server (`voiceServer.ts`) and the
40
41
  * Wyoming endpoint (`wyoming.ts`).
@@ -63,7 +64,7 @@ const esphomeProto_1 = require("./esphomeProto");
63
64
  const esphomeEntities_1 = require("./esphomeEntities");
64
65
  const vad_1 = require("./vad");
65
66
  const speechAudio_1 = require("./speechAudio");
66
- const stt_1 = require("./stt");
67
+ const replyFormat_1 = require("./replyFormat");
67
68
  const captures_1 = require("./captures");
68
69
  /** `VoiceAssistantEvent` (api.proto) — the subset we emit. */
69
70
  const EVENT = {
@@ -154,6 +155,16 @@ const STREAM_START_MS = 5000;
154
155
  const STALL_CHECK_MS = 250;
155
156
  /** Silence threshold as a share of the speech threshold — keeps the default pair 250/150. */
156
157
  const SILENCE_SHARE = 0.6;
158
+ /**
159
+ * The firmware answers an ERROR event with a stop request of its own (`signal_stop_`). One arriving this soon after
160
+ * our error belongs to it and needs no reply — the run is already over.
161
+ */
162
+ const ERROR_ECHO_MS = 2000;
163
+ /**
164
+ * What ends a run that produced no request. Home Assistant sends the same code when the speech recognition finds no
165
+ * text, and the Voice PE firmware deliberately plays no error sound for it.
166
+ */
167
+ const NO_TEXT_ERROR = { code: 'stt-no-text-recognized', message: 'No text recognized' };
157
168
  /**
158
169
  * A dropped utterance whose loudest frame reached this share of the speech threshold was probably a voice
159
170
  * that was too quiet, not an empty room — worth telling the user where to lower the threshold.
@@ -164,6 +175,12 @@ function speechLevelOf(device) {
164
175
  const level = Math.round(Number(device.speechLevel));
165
176
  return Number.isFinite(level) && level > 0 ? level : 0;
166
177
  }
178
+ /** How a configured device is identified: `ip`, or `ip:port` when it does not use the default port. */
179
+ function esphomeAddress(device) {
180
+ const ip = (device.ip || '').trim().toLowerCase();
181
+ const port = Number(device.port) || DEFAULT_PORT;
182
+ return port === DEFAULT_PORT ? ip : `${ip}:${port}`;
183
+ }
167
184
  /** `VoiceAssistantTimerEvent` in api.proto. */
168
185
  exports.TIMER_EVENT = { started: 0, updated: 1, cancelled: 2, finished: 3 };
169
186
  /**
@@ -293,13 +310,29 @@ class EsphomeConnection {
293
310
  password;
294
311
  /** Configured speech threshold, 0 = {@link DEFAULT_SPEECH_LEVEL}. */
295
312
  speechLevel;
313
+ /** `ip`, or `ip:port` for another port than the default — how the adapter recognises the table row. */
314
+ address;
315
+ /** The device's friendly name, for entities without a name of their own. */
316
+ friendlyName = '';
317
+ /**
318
+ * Number of the current pipeline run. A run whose number is no longer current was ended — the device asked to
319
+ * stop, started over, or went away — and whatever STT, answer or TTS is still in flight for it stays silent.
320
+ */
321
+ run = 0;
322
+ /** From RUN_START until we sent RUN_END: the device waits for that RUN_END. */
323
+ runOpen = false;
324
+ /** When we last sent an ERROR event (see {@link ERROR_ECHO_MS}). */
325
+ errorSentAt = 0;
326
+ /** The reply format last reported in the log, to log it again only when it changes. */
327
+ loggedFormat = '';
296
328
  constructor(device, opts) {
297
329
  this.opts = opts;
298
330
  this.host = device.ip.trim();
299
- this.port = device.port || DEFAULT_PORT;
331
+ this.port = Number(device.port) || DEFAULT_PORT;
300
332
  this.room = (device.room || '').trim();
301
333
  this.password = device.password || '';
302
334
  this.speechLevel = speechLevelOf(device);
335
+ this.address = esphomeAddress(device);
303
336
  this.deviceName = this.host;
304
337
  }
305
338
  // ── lifecycle ───────────────────────────────────────────────────────────────────────────────────
@@ -355,6 +388,9 @@ class EsphomeConnection {
355
388
  onDisconnected() {
356
389
  this.clearTimers();
357
390
  this.abortCapture();
391
+ // A reply still being worked out must not land on the next connection, in the middle of another run.
392
+ this.run++;
393
+ this.runOpen = false;
358
394
  if (this.online) {
359
395
  this.opts.log.info(`ESPHome satellite offline: ${this.deviceName}`);
360
396
  }
@@ -388,11 +424,11 @@ class EsphomeConnection {
388
424
  setOffline() {
389
425
  if (this.online) {
390
426
  this.online = false;
391
- this.opts.onStatus?.(this.deviceName, this.room, 'offline');
427
+ this.opts.onStatus?.(this.deviceName, this.room, 'offline', this.address);
392
428
  }
393
429
  }
394
430
  status(state) {
395
- this.opts.onStatus?.(this.deviceName, this.room, state);
431
+ this.opts.onStatus?.(this.deviceName, this.room, state, this.address);
396
432
  }
397
433
  // ── protocol plumbing ───────────────────────────────────────────────────────────────────────────
398
434
  send(message) {
@@ -479,6 +515,7 @@ class EsphomeConnection {
479
515
  case 'DeviceInfoResponse': {
480
516
  const info = message;
481
517
  this.deviceName = info.getName() || this.host;
518
+ this.friendlyName = info.getFriendlyName?.() || info.getName() || '';
482
519
  this.online = true;
483
520
  this.opts.log.info(`ESPHome satellite online: ${this.deviceName} (${info.getModel()}) @ ${this.host}${this.room ? `, room ${this.room}` : ''}`);
484
521
  this.status('idle');
@@ -556,7 +593,7 @@ class EsphomeConnection {
556
593
  }
557
594
  default:
558
595
  if ((0, esphomeEntities_1.isListEntityMessage)(name)) {
559
- this.entities.addFromAnnouncement(name, message);
596
+ this.entities.addFromAnnouncement(name, message, this.friendlyName);
560
597
  }
561
598
  else if ((0, esphomeEntities_1.isEntityStateMessage)(name)) {
562
599
  const entity = this.entities.applyState(name, message);
@@ -600,13 +637,19 @@ class EsphomeConnection {
600
637
  // ── voice pipeline ──────────────────────────────────────────────────────────────────────────────
601
638
  onPipelineRequest(request) {
602
639
  if (!request.getStart()) {
603
- this.abortCapture();
640
+ this.onStopRequest();
604
641
  return;
605
642
  }
606
643
  if (this.capture) {
607
644
  this.opts.log.debug(`ESPHome ${this.deviceName}: pipeline already running — ignoring wake`);
608
645
  return;
609
646
  }
647
+ if (this.runOpen) {
648
+ // The device started over while the last request was still being answered: that answer is not
649
+ // wanted any more, and its events would land in the middle of the new run.
650
+ this.opts.log.debug(`ESPHome ${this.deviceName}: new wake while answering — dropping the old reply`);
651
+ }
652
+ this.run++;
610
653
  const phrase = request.getWakeWordPhrase();
611
654
  this.opts.log.debug(`ESPHome ${this.deviceName}: wake "${phrase || '(follow-up)'}"`);
612
655
  const response = new esphomeProto_1.pb.VoiceAssistantResponse();
@@ -653,6 +696,29 @@ class EsphomeConnection {
653
696
  this.event(EVENT.STT_START);
654
697
  this.event(EVENT.STT_VAD_START);
655
698
  }
699
+ /**
700
+ * The device asks to end the conversation (`VoiceAssistantRequest{start:false}`): a button, or the wake word
701
+ * said again while a request is running — that is how ESPHome's voice_assistant stops. While it waits for an
702
+ * answer, the firmware leaves that state only on our RUN_END; without one it stays "in the conversation", and
703
+ * every further wake word just sends another stop (field report 2026-11-03).
704
+ */
705
+ onStopRequest() {
706
+ const running = !!this.capture || this.runOpen;
707
+ this.abortCapture();
708
+ if (running) {
709
+ this.run++; // whatever is still being answered stays silent
710
+ this.opts.log.debug(`ESPHome ${this.deviceName}: the device ended the conversation`);
711
+ this.event(EVENT.RUN_END);
712
+ this.status('idle');
713
+ return;
714
+ }
715
+ if (Date.now() - this.errorSentAt < ERROR_ECHO_MS) {
716
+ return; // the device's own reaction to our ERROR event
717
+ }
718
+ // Nothing is open on our side, but the device still thinks so (it missed a RUN_END): end it there too.
719
+ this.opts.log.debug(`ESPHome ${this.deviceName}: stop outside a conversation — ending it on the device`);
720
+ this.event(EVENT.RUN_END);
721
+ }
656
722
  onAudio(audio) {
657
723
  const capture = this.capture;
658
724
  if (!capture || capture.closed) {
@@ -718,6 +784,15 @@ class EsphomeConnection {
718
784
  this.capture = null;
719
785
  clearTimeout(capture.timer);
720
786
  clearInterval(capture.stallCheck);
787
+ const run = this.run;
788
+ /** False once the run was ended elsewhere — then nothing more may be sent for it. */
789
+ const current = () => {
790
+ if (run === this.run) {
791
+ return true;
792
+ }
793
+ this.opts.log.debug(`ESPHome ${this.deviceName}: conversation ended meanwhile — dropping the reply`);
794
+ return false;
795
+ };
721
796
  // Send this first: the device streams until it sees it.
722
797
  this.event(EVENT.STT_VAD_END);
723
798
  const pcm = Buffer.concat(capture.chunks);
@@ -747,13 +822,15 @@ class EsphomeConnection {
747
822
  this.opts.log.debug(`ESPHome ${this.deviceName}: nothing but silence${end === 'no-speech' ? ' (nobody started speaking)' : ''} — dropping the utterance`);
748
823
  }
749
824
  this.discard(capture, pcm, end === 'no-speech' ? 'no-speech' : 'silence', vad);
750
- this.event(EVENT.RUN_END);
751
- this.status('idle');
825
+ this.endWithoutRequest();
752
826
  return;
753
827
  }
754
828
  this.status('processing');
755
829
  try {
756
830
  const hints = this.opts.getHints ? await this.opts.getHints() : undefined;
831
+ if (!current()) {
832
+ return;
833
+ }
757
834
  const speech = (0, speechAudio_1.prepareForStt)(pcm, MIC_SAMPLE_RATE, { startMs: vad.speechStartMs, endLevel: vad.endLevel });
758
835
  vad.stt = { fromMs: speech.fromMs, toMs: speech.toMs, gain: speech.gain };
759
836
  this.opts.log.debug(`ESPHome ${this.deviceName}: speech recognition gets ${(speech.fromMs / 1000).toFixed(2)}–` +
@@ -761,16 +838,19 @@ class EsphomeConnection {
761
838
  const sttStart = Date.now();
762
839
  const text = (await this.opts.stt.transcribe(speech.pcm, MIC_SAMPLE_RATE, this.opts.language, hints)).trim();
763
840
  const sttMs = Date.now() - sttStart;
764
- this.event(EVENT.STT_END, { text });
841
+ if (!current()) {
842
+ return;
843
+ }
765
844
  if (!text) {
766
845
  // Speech was heard, so the user waits for an answer: say why none comes. Typically a word in another
767
846
  // language than the STT listens in (a German "Ja" to a Russian STT comes back empty).
768
847
  this.opts.log.info(`Voice Q (${this.deviceName}): (empty) — speech recognition returned no text for ${seconds.toFixed(1)} s of audio`);
769
848
  this.discard(capture, pcm, 'empty-stt', vad);
770
- this.event(EVENT.RUN_END);
771
- this.status('idle');
849
+ // No STT_END without text — like Home Assistant, which reports this case as an error only.
850
+ this.endWithoutRequest();
772
851
  return;
773
852
  }
853
+ this.event(EVENT.STT_END, { text });
774
854
  this.event(EVENT.INTENT_START);
775
855
  const answer = (await this.opts.answer(text, {
776
856
  device: this.deviceName,
@@ -781,6 +861,9 @@ class EsphomeConnection {
781
861
  // Numbers only, no audio: shows where a request was cut off without keeping recordings.
782
862
  vad: { ...vad, levels: (0, captures_1.levelProfile)(pcm, MIC_SAMPLE_RATE) },
783
863
  })).trim();
864
+ if (!current()) {
865
+ return;
866
+ }
784
867
  const followUp = !!answer && this.opts.expectsFollowUp?.(answer, this.deviceName) === true;
785
868
  this.event(EVENT.INTENT_END, followUp ? { continue_conversation: '1' } : undefined);
786
869
  if (!answer) {
@@ -790,11 +873,17 @@ class EsphomeConnection {
790
873
  }
791
874
  const ttsStart = Date.now();
792
875
  const { pcm: ttsPcm, sampleRate } = await this.opts.tts.synthesize(answer, this.opts.language);
876
+ if (!current()) {
877
+ return;
878
+ }
793
879
  this.opts.onSpoken?.(this.deviceName, {
794
880
  ms: Date.now() - ttsStart,
795
881
  audioMs: (ttsPcm.length / 2 / sampleRate) * 1000,
796
882
  });
797
- const url = this.publish(ttsPcm, sampleRate);
883
+ const url = await this.publish(ttsPcm, sampleRate);
884
+ if (!current()) {
885
+ return;
886
+ }
798
887
  this.status('speaking');
799
888
  this.event(EVENT.TTS_START, { text: answer });
800
889
  this.event(EVENT.TTS_END, { url });
@@ -804,12 +893,26 @@ class EsphomeConnection {
804
893
  catch (e) {
805
894
  const reason = e.message;
806
895
  this.opts.log.error(`ESPHome ${this.deviceName}: voice pipeline failed — ${reason}`);
896
+ if (run !== this.run) {
897
+ return; // the conversation is over already — nothing to tell the device
898
+ }
807
899
  // Without this the device stays ducked and its LED keeps spinning.
808
900
  this.event(EVENT.ERROR, { code: 'pipeline-failed', message: reason });
809
901
  this.event(EVENT.RUN_END);
810
902
  this.status('idle');
811
903
  }
812
904
  }
905
+ /**
906
+ * End a run that produced no request. A RUN_END right after STT_VAD_END gets lost: the firmware is still stopping
907
+ * its microphone then, ignores RUN_END in that state, and afterwards waits for an answer that never comes — the
908
+ * satellite stays in the conversation (field report 2026-11-03). An ERROR event puts it back to idle in any state,
909
+ * so it goes first, with the code Home Assistant uses for this case.
910
+ */
911
+ endWithoutRequest() {
912
+ this.event(EVENT.ERROR, NO_TEXT_ERROR);
913
+ this.event(EVENT.RUN_END);
914
+ this.status('idle');
915
+ }
813
916
  /**
814
917
  * Select which wake words listen on this device. Unknown ids are dropped and the list is trimmed
815
918
  * to what the firmware allows, because the device answers a rejected list with silence rather than
@@ -886,12 +989,12 @@ class EsphomeConnection {
886
989
  * Speak a ready PCM buffer on this device (timers, alarms, `tts.text`, jingles). With
887
990
  * `startConversation` the microphone opens once the clip has played, to catch the answer to a question.
888
991
  */
889
- announce(pcm, sampleRate, startConversation = false) {
992
+ async announce(pcm, sampleRate, startConversation = false) {
890
993
  if (!this.online || !pcm.length) {
891
- return Promise.resolve();
994
+ return;
892
995
  }
893
996
  const request = new esphomeProto_1.pb.VoiceAssistantAnnounceRequest();
894
- request.setMediaId(this.publish(pcm, sampleRate));
997
+ request.setMediaId(await this.publish(pcm, sampleRate));
895
998
  request.setText('');
896
999
  request.setPreannounceMediaId('');
897
1000
  // Needs feature flag 32 (START_CONVERSATION); on a device without it the clip still plays and
@@ -900,16 +1003,45 @@ class EsphomeConnection {
900
1003
  this.status('speaking');
901
1004
  this.send(request);
902
1005
  this.armPlaybackTimeout();
903
- return Promise.resolve();
1006
+ }
1007
+ /** The format replies should have on this device, from what its media player announced. */
1008
+ replyFormat() {
1009
+ const formats = this.entities
1010
+ .list()
1011
+ .filter(e => e.kind === 'mediaPlayer')
1012
+ .flatMap(e => e.formats ?? []);
1013
+ return (0, replyFormat_1.chooseReplyFormat)(formats, !!this.opts.encodeAudio);
904
1014
  }
905
1015
  /**
906
- * Publish PCM as a WAV on the media server and build the URL the device should fetch.
1016
+ * Publish PCM on the media server, in the format the device plays, and build the URL it should fetch.
907
1017
  *
908
1018
  * The host matters: on a multi-homed ioBroker machine the device can usually only reach one of the
909
1019
  * addresses, and the local end of *this* socket is by definition one it can talk to.
910
1020
  */
911
- publish(pcm, sampleRate) {
912
- return this.mediaUrl(this.opts.media.publish((0, stt_1.pcmToWav)(pcm, sampleRate), 'audio/wav'));
1021
+ async publish(pcm, sampleRate) {
1022
+ let format = this.replyFormat();
1023
+ let encoded;
1024
+ try {
1025
+ encoded = await (0, replyFormat_1.encodeReply)(pcm, sampleRate, format, this.opts.encodeAudio);
1026
+ }
1027
+ catch (e) {
1028
+ // MP3/Opus need ffmpeg. Without it a native format is the only chance, whether the device lists it or not.
1029
+ const fallback = { ...format, codec: 'flac', source: `${format.source}, FLAC as fallback` };
1030
+ this.opts.log.warn(`ESPHome ${this.deviceName}: cannot write the reply as ${format.codec.toUpperCase()} ` +
1031
+ `(${e.message}) — sending FLAC, which the device may not play. Install ffmpeg.`);
1032
+ format = fallback;
1033
+ encoded = await (0, replyFormat_1.encodeReply)(pcm, sampleRate, format);
1034
+ }
1035
+ const key = `${format.codec}/${format.sampleRate}/${format.channels}`;
1036
+ if (key !== this.loggedFormat) {
1037
+ this.loggedFormat = key;
1038
+ const what = !format.source
1039
+ ? 'WAV (the device lists no formats)'
1040
+ : `${format.codec.toUpperCase()}${format.sampleRate ? ` ${format.sampleRate} Hz` : ''}` +
1041
+ `${format.channels === 2 ? ' stereo' : ''} (device: ${format.source})`;
1042
+ this.opts.log.info(`ESPHome ${this.deviceName}: spoken replies as ${what}`);
1043
+ }
1044
+ return this.mediaUrl(this.opts.media.publish(encoded.body, encoded.contentType));
913
1045
  }
914
1046
  /** Absolute URL of a path on the media server, with a host this device can reach. */
915
1047
  mediaUrl(path) {
@@ -931,6 +1063,15 @@ class EsphomeConnection {
931
1063
  this.playbackTimer.unref();
932
1064
  }
933
1065
  event(type, data) {
1066
+ if (type === EVENT.RUN_START) {
1067
+ this.runOpen = true;
1068
+ }
1069
+ else if (type === EVENT.RUN_END) {
1070
+ this.runOpen = false;
1071
+ }
1072
+ else if (type === EVENT.ERROR) {
1073
+ this.errorSentAt = Date.now();
1074
+ }
934
1075
  const message = new esphomeProto_1.pb.VoiceAssistantEventResponse();
935
1076
  message.setEventType(type);
936
1077
  const items = [];