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.
- package/README.md +10 -0
- package/admin/custom/.vite/manifest.json +1 -1
- package/admin/custom/assets/Components-BR57MZLJ.js +2 -0
- package/admin/custom/customComponents.js +1 -1
- package/admin/custom/i18n/de.json +3 -0
- package/admin/custom/i18n/en.json +3 -0
- package/admin/custom/i18n/es.json +3 -0
- package/admin/custom/i18n/fr.json +3 -0
- package/admin/custom/i18n/it.json +3 -0
- package/admin/custom/i18n/nl.json +3 -0
- package/admin/custom/i18n/pl.json +3 -0
- package/admin/custom/i18n/pt.json +3 -0
- package/admin/custom/i18n/ru.json +3 -0
- package/admin/custom/i18n/uk.json +3 -0
- package/admin/custom/i18n/zh-cn.json +3 -0
- package/admin/custom/index.html +1 -1
- package/admin/custom/mf-manifest.json +1 -1
- package/admin/custom/mf-stats.json +1 -1
- package/admin/i18n/de.json +6 -2
- package/admin/i18n/en.json +6 -2
- package/admin/i18n/es.json +6 -2
- package/admin/i18n/fr.json +6 -2
- package/admin/i18n/it.json +6 -2
- package/admin/i18n/nl.json +6 -2
- package/admin/i18n/pl.json +6 -2
- package/admin/i18n/pt.json +6 -2
- package/admin/i18n/ru.json +6 -2
- package/admin/i18n/uk.json +6 -2
- package/admin/i18n/zh-cn.json +6 -2
- package/admin/jsonConfig.json +24 -2
- package/build/lib/voice/engines.js +3 -0
- package/build/lib/voice/engines.js.map +1 -1
- package/build/lib/voice/esphome.js +160 -19
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/esphomeEntities.js +76 -6
- package/build/lib/voice/esphomeEntities.js.map +1 -1
- package/build/lib/voice/esphomeProto.js.map +1 -1
- package/build/lib/voice/flac.js +162 -0
- package/build/lib/voice/flac.js.map +1 -0
- package/build/lib/voice/mediaServer.js +8 -1
- package/build/lib/voice/mediaServer.js.map +1 -1
- package/build/lib/voice/replyFormat.js +158 -0
- package/build/lib/voice/replyFormat.js.map +1 -0
- package/build/lib/voice/stt.js +37 -6
- package/build/lib/voice/stt.js.map +1 -1
- package/build/main.js +165 -4
- package/build/main.js.map +1 -1
- package/io-package.json +33 -29
- package/package.json +1 -1
- package/admin/custom/assets/Components-CCl7INIF.js +0 -2
package/admin/i18n/pl.json
CHANGED
|
@@ -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.": "
|
|
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
|
}
|
package/admin/i18n/pt.json
CHANGED
|
@@ -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
|
}
|
package/admin/i18n/ru.json
CHANGED
|
@@ -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
|
}
|
package/admin/i18n/uk.json
CHANGED
|
@@ -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.": "
|
|
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
|
}
|
package/admin/i18n/zh-cn.json
CHANGED
|
@@ -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
|
}
|
package/admin/jsonConfig.json
CHANGED
|
@@ -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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsGA,0CAYC;AA0BD,0CAeC;AAuDD,gCAmBC;AAGD,sCAKC;AAGD,sCAEC;AAlPD;;;;GAIG;AACH,+BAAkD;AAClD,+BAAoE;AACpE,mCAA8D;AAC9D,+BAAuE;AACvE,iCAAiD;AACjD,mCAAoD;AACpD,6CAOsB;AACtB,gDAAkC;AAElC,yCAAuC;AACvC,yCAAwE;AACxE,iCAAqC;AAuDrC,SAAS,YAAY,CAAC,KAAuB;IACzC,IAAI,CAAC,KAAK,CAAC,QAAQ,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QACxC,MAAM,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAC;IACxE,CAAC;AACL,CAAC;AAED,SAAS,UAAU,CAAC,KAAuB;IACvC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,eAAe,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC;QAC5E,MAAM,IAAI,KAAK,CAAC,iEAAiE,CAAC,CAAC;IACvF,CAAC;AACL,CAAC;AAED,SAAS,aAAa,CAAC,KAAuB;IAC1C,IAAI,CAAC,KAAK,CAAC,SAAS,EAAE,CAAC;QACnB,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;IACtD,CAAC;AACL,CAAC;AAED,SAAS,iBAAiB,CAAC,KAAuB;IAC9C,IAAI,CAAC,KAAK,CAAC,aAAa,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;IAC1D,CAAC;AACL,CAAC;AAED,SAAgB,eAAe,CAAC,QAAwB,EAAE,GAAkB;IACxE,MAAM,OAAO,GAA2B,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,cAAc,CAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,CAAC;IAClG,MAAM,QAAQ,GAAG,GAAG,CAAC,WAAW,IAAI,GAAG,CAAC,WAAW,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC;IACxF,IAAI,CAAC,QAAQ,EAAE,CAAC;QACZ,OAAO,OAAO,CAAC,MAAM,CAAC;IAC1B,CAAC;IACD,IAAI,CAAC;QACD,OAAO,IAAI,sBAAW,CAAC,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,cAAc,CAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;IAC1G,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACT,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,4BAA4B,QAAQ,kBAAmB,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;QAC3F,OAAO,OAAO,CAAC,MAAM,CAAC;IAC1B,CAAC;AACL,CAAC;AAED,SAAS,cAAc,CAAC,QAAwB,EAAE,GAAkB;IAChE,QAAQ,QAAQ,EAAE,CAAC;QACf,KAAK,OAAO;YACR,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACxB,OAAO,IAAI,gBAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;QACnE,KAAK,KAAK;YACN,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACtB,OAAO,IAAI,YAAM,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACrC,KAAK,MAAM;YACP,OAAO,IAAI,cAAO,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QAC5D,KAAK,YAAY;YACb,iBAAiB,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YAC7B,OAAO,IAAI,0BAAa,CAAC,GAAG,CAAC,KAAK,CAAC,aAAa,EAAE,GAAG,CAAC,kBAAkB,CAAC,CAAC;QAC9E,KAAK,OAAO;YACR,MAAM,IAAI,KAAK,CAAC,sDAAsD,CAAC,CAAC;QAC5E;YACI,IAAI,GAAG,CAAC,UAAU,EAAE,IAAI,EAAE,EAAE,CAAC;gBACzB,OAAO,IAAI,eAAS,CAAC,GAAG,CAAC,YAAY,IAAI,EAAE,EAAE,GAAG,CAAC,QAAQ,IAAI,SAAS,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;YAC5F,CAAC;YACD,aAAa,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACzB,OAAO,IAAI,eAAS,CAAC,GAAG,CAAC,KAAK,CAAC,SAAS,EAAE,GAAG,CAAC,QAAQ,IAAI,SAAS,CAAC,CAAC;IAC7E,CAAC;AACL,CAAC;AAED,SAAgB,eAAe,CAAC,QAAwB,EAAE,GAAkB;IACxE,sGAAsG;IACtG,iGAAiG;IACjG,MAAM,OAAO,GAA2B,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,CAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,CAAC;IAC7F,MAAM,QAAQ,GAAG,GAAG,CAAC,WAAW,IAAI,GAAG,CAAC,WAAW,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC;IACxF,IAAI,CAAC,QAAQ,EAAE,CAAC;QACZ,OAAO,OAAO,CAAC,MAAM,CAAC;IAC1B,CAAC;IACD,IAAI,CAAC;QACD,OAAO,IAAI,sBAAW,CAAC,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,CAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;IACrG,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACT,sGAAsG;QACtG,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,4BAA4B,QAAQ,kBAAmB,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;QAC3F,OAAO,OAAO,CAAC,MAAM,CAAC;IAC1B,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,SAAS,SAAS,CAAC,QAAwB,EAAE,GAAkB;IAC3D,MAAM,KAAK,GACP,QAAQ,KAAK,OAAO;QAChB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK;QAClB,CAAC,CAAC,QAAQ,KAAK,KAAK;YAClB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG;YAChB,CAAC,CAAC,QAAQ,KAAK,OAAO;gBACpB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK;gBAClB,CAAC,CAAC,QAAQ,KAAK,YAAY;oBACzB,CAAC,CAAC,GAAG,GAAG,CAAC,kBAAkB,IAAI,yCAA4B,IAAI,GAAG,CAAC,MAAM,CAAC,UAAU,EAAE;oBACtF,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC;IAClC,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,QAAQ,IAAI,CAAC,KAAK,IAAI,SAAS,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;IAC/G,OAAO,IAAI,oBAAS,CAAC,cAAc,CAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,EAAE,QAAQ,EAAE,GAAG,CAAC,WAAW,EAAE,CAAC,CAAC;AAC1G,CAAC;AAED,SAAS,cAAc,CAAC,QAAwB,EAAE,GAAkB;IAChE,QAAQ,QAAQ,EAAE,CAAC;QACf,KAAK,OAAO;YACR,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACxB,OAAO,IAAI,gBAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,KAAK,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACrF,KAAK,KAAK;YACN,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACtB,OAAO,IAAI,YAAM,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACrD,KAAK,OAAO;YACR,OAAO,IAAI,gBAAQ,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QAChE,KAAK,MAAM;YACP,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;QAC3E,KAAK,YAAY;YACb,iBAAiB,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YAC7B,OAAO,IAAI,0BAAa,CACpB,GAAG,CAAC,KAAK,CAAC,aAAa,EACvB,GAAG,CAAC,kBAAkB,EACtB,GAAG,CAAC,MAAM,CAAC,UAAU,EACrB,GAAG,CAAC,WAAW,CAClB,CAAC;QACN;YACI,aAAa,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACzB,OAAO,IAAI,eAAS,CAChB,GAAG,CAAC,KAAK,CAAC,SAAS,EACnB,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,IAAI,OAAO,CAAgB,EAC7C,GAAG,CAAC,QAAQ,IAAI,SAAS,CAC5B,CAAC;IACV,CAAC;AACL,CAAC;AAED,uDAAuD;AACvD,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC;AAE5E,sFAAsF;AAC/E,KAAK,UAAU,UAAU,CAAC,QAAwB,EAAE,GAAkB,EAAE,QAAgB;IAC3F,QAAQ,QAAQ,EAAE,CAAC;QACf,KAAK,OAAO;YACR,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACxB,OAAO,IAAA,uBAAe,EAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,KAAK,CAAC,WAAW,EAAE,IAAA,kBAAW,EAAC,QAAQ,CAAC,CAAC,CAAC;QAC7F,KAAK,KAAK;YACN,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACtB,OAAO,IAAA,qBAAe,EAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,IAAA,kBAAW,EAAC,QAAQ,CAAC,CAAC,CAAC;QACjE,KAAK,OAAO;YACR,OAAO,IAAA,uBAAe,GAAE,CAAC;QAC7B,KAAK,YAAY,CAAC,CAAC,CAAC;YAChB,6DAA6D;YAC7D,MAAM,KAAK,GAAG,GAAG,CAAC,kBAAkB,IAAI,yCAA4B,CAAC;YACrE,MAAM,MAAM,GAAG,MAAM,IAAA,uCAA0B,EAAC,QAAQ,CAAC,CAAC;YAC1D,OAAO,IAAA,8BAAiB,EAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,KAAK,CAAC,EAAE,MAAM,IAAI,EAAE,EAAE,QAAQ,CAAC,CAAC;QACvF,CAAC;QACD;YACI,OAAO,aAAa,CAAC;IAC7B,CAAC;AACL,CAAC;AAED,kHAAkH;AAC3G,KAAK,UAAU,aAAa,CAAC,QAAwB,EAAE,QAAgB;IAC1E,IAAI,QAAQ,KAAK,YAAY,EAAE,CAAC;QAC5B,OAAO,CAAC,MAAM,IAAA,uCAA0B,EAAC,eAAe,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAC9E,CAAC;IACD,OAAO,QAAQ,KAAK,MAAM,CAAC,CAAC,CAAC,IAAA,qBAAc,EAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AAC/D,CAAC;AAED,8FAA8F;AACvF,KAAK,UAAU,aAAa,CAAC,QAAwB;IACxD,OAAO,QAAQ,KAAK,YAAY,CAAC,CAAC,CAAC,CAAC,MAAM,IAAA,uCAA0B,EAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AACxG,CAAC","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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
771
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 = [];
|