iobroker.assistant 1.4.3 → 1.4.5
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 +11 -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/build/lib/voice/esphome.js +209 -23
- 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 +2 -3
- package/build/lib/voice/stt.js.map +1 -1
- package/build/lib/voice/vad.js +42 -5
- package/build/lib/voice/vad.js.map +1 -1
- package/build/main.js +153 -3
- package/build/main.js.map +1 -1
- package/io-package.json +27 -27
- package/package.json +1 -1
- package/admin/custom/assets/Components-CCl7INIF.js +0 -2
|
@@ -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 = {
|
|
@@ -136,8 +137,34 @@ const DEFAULT_SILENCE_MS = 900;
|
|
|
136
137
|
*/
|
|
137
138
|
const LONG_REQUEST_MS = 2500;
|
|
138
139
|
const LONG_PAUSE_FACTOR = 1.5;
|
|
140
|
+
/**
|
|
141
|
+
* Sound needed before a loud moment counts as speech. On vitanova (2026-11-03) a single 100 ms sound at RMS 197
|
|
142
|
+
* after a false wake opened the gate (threshold 150), and Whisper made "и все просят." of the amplified silence
|
|
143
|
+
* around it. The shortest real answer, "да", lasts about 250 ms.
|
|
144
|
+
*/
|
|
145
|
+
const MIN_SPEECH_MS = 200;
|
|
146
|
+
/**
|
|
147
|
+
* Close the utterance when the device stops sending audio without ending the stream. Seen on a ThirdReality
|
|
148
|
+
* speaker: the audio stopped after 2.7 s, and the satellite showed "listening" for the 30 s of
|
|
149
|
+
* {@link PIPELINE_TIMEOUT_MS}. A device streams every few tens of ms while the mic is open.
|
|
150
|
+
*/
|
|
151
|
+
const STREAM_STALL_MS = 2000;
|
|
152
|
+
/** The same before the first audio arrives: a device needs a moment to open the microphone. */
|
|
153
|
+
const STREAM_START_MS = 5000;
|
|
154
|
+
/** How often a running capture checks for a stalled stream. */
|
|
155
|
+
const STALL_CHECK_MS = 250;
|
|
139
156
|
/** Silence threshold as a share of the speech threshold — keeps the default pair 250/150. */
|
|
140
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' };
|
|
141
168
|
/**
|
|
142
169
|
* A dropped utterance whose loudest frame reached this share of the speech threshold was probably a voice
|
|
143
170
|
* that was too quiet, not an empty room — worth telling the user where to lower the threshold.
|
|
@@ -148,6 +175,12 @@ function speechLevelOf(device) {
|
|
|
148
175
|
const level = Math.round(Number(device.speechLevel));
|
|
149
176
|
return Number.isFinite(level) && level > 0 ? level : 0;
|
|
150
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
|
+
}
|
|
151
184
|
/** `VoiceAssistantTimerEvent` in api.proto. */
|
|
152
185
|
exports.TIMER_EVENT = { started: 0, updated: 1, cancelled: 2, finished: 3 };
|
|
153
186
|
/**
|
|
@@ -277,13 +310,29 @@ class EsphomeConnection {
|
|
|
277
310
|
password;
|
|
278
311
|
/** Configured speech threshold, 0 = {@link DEFAULT_SPEECH_LEVEL}. */
|
|
279
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 = '';
|
|
280
328
|
constructor(device, opts) {
|
|
281
329
|
this.opts = opts;
|
|
282
330
|
this.host = device.ip.trim();
|
|
283
|
-
this.port = device.port || DEFAULT_PORT;
|
|
331
|
+
this.port = Number(device.port) || DEFAULT_PORT;
|
|
284
332
|
this.room = (device.room || '').trim();
|
|
285
333
|
this.password = device.password || '';
|
|
286
334
|
this.speechLevel = speechLevelOf(device);
|
|
335
|
+
this.address = esphomeAddress(device);
|
|
287
336
|
this.deviceName = this.host;
|
|
288
337
|
}
|
|
289
338
|
// ── lifecycle ───────────────────────────────────────────────────────────────────────────────────
|
|
@@ -339,6 +388,9 @@ class EsphomeConnection {
|
|
|
339
388
|
onDisconnected() {
|
|
340
389
|
this.clearTimers();
|
|
341
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;
|
|
342
394
|
if (this.online) {
|
|
343
395
|
this.opts.log.info(`ESPHome satellite offline: ${this.deviceName}`);
|
|
344
396
|
}
|
|
@@ -372,11 +424,11 @@ class EsphomeConnection {
|
|
|
372
424
|
setOffline() {
|
|
373
425
|
if (this.online) {
|
|
374
426
|
this.online = false;
|
|
375
|
-
this.opts.onStatus?.(this.deviceName, this.room, 'offline');
|
|
427
|
+
this.opts.onStatus?.(this.deviceName, this.room, 'offline', this.address);
|
|
376
428
|
}
|
|
377
429
|
}
|
|
378
430
|
status(state) {
|
|
379
|
-
this.opts.onStatus?.(this.deviceName, this.room, state);
|
|
431
|
+
this.opts.onStatus?.(this.deviceName, this.room, state, this.address);
|
|
380
432
|
}
|
|
381
433
|
// ── protocol plumbing ───────────────────────────────────────────────────────────────────────────
|
|
382
434
|
send(message) {
|
|
@@ -463,6 +515,7 @@ class EsphomeConnection {
|
|
|
463
515
|
case 'DeviceInfoResponse': {
|
|
464
516
|
const info = message;
|
|
465
517
|
this.deviceName = info.getName() || this.host;
|
|
518
|
+
this.friendlyName = info.getFriendlyName?.() || info.getName() || '';
|
|
466
519
|
this.online = true;
|
|
467
520
|
this.opts.log.info(`ESPHome satellite online: ${this.deviceName} (${info.getModel()}) @ ${this.host}${this.room ? `, room ${this.room}` : ''}`);
|
|
468
521
|
this.status('idle');
|
|
@@ -540,7 +593,7 @@ class EsphomeConnection {
|
|
|
540
593
|
}
|
|
541
594
|
default:
|
|
542
595
|
if ((0, esphomeEntities_1.isListEntityMessage)(name)) {
|
|
543
|
-
this.entities.addFromAnnouncement(name, message);
|
|
596
|
+
this.entities.addFromAnnouncement(name, message, this.friendlyName);
|
|
544
597
|
}
|
|
545
598
|
else if ((0, esphomeEntities_1.isEntityStateMessage)(name)) {
|
|
546
599
|
const entity = this.entities.applyState(name, message);
|
|
@@ -584,13 +637,19 @@ class EsphomeConnection {
|
|
|
584
637
|
// ── voice pipeline ──────────────────────────────────────────────────────────────────────────────
|
|
585
638
|
onPipelineRequest(request) {
|
|
586
639
|
if (!request.getStart()) {
|
|
587
|
-
this.
|
|
640
|
+
this.onStopRequest();
|
|
588
641
|
return;
|
|
589
642
|
}
|
|
590
643
|
if (this.capture) {
|
|
591
644
|
this.opts.log.debug(`ESPHome ${this.deviceName}: pipeline already running — ignoring wake`);
|
|
592
645
|
return;
|
|
593
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++;
|
|
594
653
|
const phrase = request.getWakeWordPhrase();
|
|
595
654
|
this.opts.log.debug(`ESPHome ${this.deviceName}: wake "${phrase || '(follow-up)'}"`);
|
|
596
655
|
const response = new esphomeProto_1.pb.VoiceAssistantResponse();
|
|
@@ -599,9 +658,11 @@ class EsphomeConnection {
|
|
|
599
658
|
this.send(response);
|
|
600
659
|
const timer = setTimeout(() => {
|
|
601
660
|
this.opts.log.warn(`ESPHome ${this.deviceName}: no end of speech within ${PIPELINE_TIMEOUT_MS} ms`);
|
|
602
|
-
void this.finishCapture();
|
|
661
|
+
void this.finishCapture('pipeline-timeout');
|
|
603
662
|
}, PIPELINE_TIMEOUT_MS);
|
|
604
663
|
timer.unref();
|
|
664
|
+
const stallCheck = setInterval(() => this.checkStall(), STALL_CHECK_MS);
|
|
665
|
+
stallCheck.unref();
|
|
605
666
|
const silenceMs = this.opts.silenceMs || DEFAULT_SILENCE_MS;
|
|
606
667
|
this.capture = {
|
|
607
668
|
chunks: [],
|
|
@@ -617,10 +678,14 @@ class EsphomeConnection {
|
|
|
617
678
|
startTimeoutMs: phrase ? NO_SPEECH_MS : NO_SPEECH_FOLLOW_UP_MS,
|
|
618
679
|
minStartLevel: this.speechLevel || exports.DEFAULT_SPEECH_LEVEL,
|
|
619
680
|
minEndLevel: Math.round((this.speechLevel || exports.DEFAULT_SPEECH_LEVEL) * SILENCE_SHARE),
|
|
681
|
+
minSpeechMs: MIN_SPEECH_MS,
|
|
620
682
|
}),
|
|
621
683
|
conversationId: request.getConversationId(),
|
|
622
684
|
wakeWord: phrase || '',
|
|
623
685
|
timer,
|
|
686
|
+
stallCheck,
|
|
687
|
+
startedAt: Date.now(),
|
|
688
|
+
lastAudioAt: 0,
|
|
624
689
|
closed: false,
|
|
625
690
|
};
|
|
626
691
|
this.status('listening');
|
|
@@ -631,6 +696,29 @@ class EsphomeConnection {
|
|
|
631
696
|
this.event(EVENT.STT_START);
|
|
632
697
|
this.event(EVENT.STT_VAD_START);
|
|
633
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
|
+
}
|
|
634
722
|
onAudio(audio) {
|
|
635
723
|
const capture = this.capture;
|
|
636
724
|
if (!capture || capture.closed) {
|
|
@@ -638,6 +726,7 @@ class EsphomeConnection {
|
|
|
638
726
|
}
|
|
639
727
|
const chunk = Buffer.from(audio.getData_asU8());
|
|
640
728
|
if (chunk.length) {
|
|
729
|
+
capture.lastAudioAt = Date.now();
|
|
641
730
|
capture.chunks.push(chunk);
|
|
642
731
|
const end = capture.detector.push(chunk);
|
|
643
732
|
if (end) {
|
|
@@ -646,8 +735,23 @@ class EsphomeConnection {
|
|
|
646
735
|
}
|
|
647
736
|
}
|
|
648
737
|
if (audio.getEnd()) {
|
|
649
|
-
void this.finishCapture();
|
|
738
|
+
void this.finishCapture('stream');
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/** End a capture whose device stopped sending audio without ending the stream. */
|
|
742
|
+
checkStall() {
|
|
743
|
+
const capture = this.capture;
|
|
744
|
+
if (!capture || capture.closed) {
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const now = Date.now();
|
|
748
|
+
const quietMs = capture.lastAudioAt ? now - capture.lastAudioAt : now - capture.startedAt;
|
|
749
|
+
if (quietMs < (capture.lastAudioAt ? STREAM_STALL_MS : STREAM_START_MS)) {
|
|
750
|
+
return;
|
|
650
751
|
}
|
|
752
|
+
const audioMs = Math.round(capture.chunks.reduce((sum, c) => sum + c.length, 0) / 2 / (MIC_SAMPLE_RATE / 1000));
|
|
753
|
+
this.opts.log.info(`ESPHome ${this.deviceName}: the device ${capture.lastAudioAt ? `stopped sending audio after ${(audioMs / 1000).toFixed(2)} s` : 'sent no audio'} without ending the stream — closing the utterance after ${(quietMs / 1000).toFixed(1)} s of nothing`);
|
|
754
|
+
void this.finishCapture('stall');
|
|
651
755
|
}
|
|
652
756
|
/** Hand a recording that never became a request to {@link EsphomeSatellitesOptions.onDiscarded}. */
|
|
653
757
|
discard(capture, pcm, reason, vad) {
|
|
@@ -679,11 +783,21 @@ class EsphomeConnection {
|
|
|
679
783
|
capture.closed = true;
|
|
680
784
|
this.capture = null;
|
|
681
785
|
clearTimeout(capture.timer);
|
|
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
|
+
};
|
|
682
796
|
// Send this first: the device streams until it sees it.
|
|
683
797
|
this.event(EVENT.STT_VAD_END);
|
|
684
798
|
const pcm = Buffer.concat(capture.chunks);
|
|
685
799
|
const seconds = pcm.length / 2 / MIC_SAMPLE_RATE;
|
|
686
|
-
const vad = { ...capture.detector.report(), end
|
|
800
|
+
const vad = { ...capture.detector.report(), end };
|
|
687
801
|
const startedAt = vad.speechStartMs !== undefined ? `, speech from ${(vad.speechStartMs / 1000).toFixed(2)} s` : '';
|
|
688
802
|
const endedBy = vad.end === 'speech-end' ? `ended after ${vad.silenceMs} ms of silence` : `ended by ${vad.end}`;
|
|
689
803
|
this.opts.log.debug(`ESPHome ${this.deviceName}: captured ${seconds.toFixed(2)} s ` +
|
|
@@ -692,7 +806,12 @@ class EsphomeConnection {
|
|
|
692
806
|
if (!pcm.length || !capture.detector.sawSpeech) {
|
|
693
807
|
const loudest = Math.round(capture.detector.analysedPeak);
|
|
694
808
|
const gate = Math.round(capture.detector.startLevel);
|
|
695
|
-
if (
|
|
809
|
+
if (capture.detector.longestSoundMs > 0) {
|
|
810
|
+
// Something crossed the gate, but too briefly for a word: a click, a knock, a cough.
|
|
811
|
+
this.opts.log.debug(`ESPHome ${this.deviceName}: only a short sound (${capture.detector.longestSoundMs} ms, ` +
|
|
812
|
+
`RMS ${loudest}) — too short for speech (${MIN_SPEECH_MS} ms), dropping the utterance`);
|
|
813
|
+
}
|
|
814
|
+
else if (pcm.length && loudest >= gate * QUIET_VOICE_SHARE) {
|
|
696
815
|
// Not an empty room: something came close to the gate. Typically a microphone that is quieter
|
|
697
816
|
// than the default threshold expects (a phone) — say where to change it.
|
|
698
817
|
this.opts.log.info(`ESPHome ${this.deviceName}: dropped the utterance — the loudest sound (RMS ${loudest}) stayed ` +
|
|
@@ -703,13 +822,15 @@ class EsphomeConnection {
|
|
|
703
822
|
this.opts.log.debug(`ESPHome ${this.deviceName}: nothing but silence${end === 'no-speech' ? ' (nobody started speaking)' : ''} — dropping the utterance`);
|
|
704
823
|
}
|
|
705
824
|
this.discard(capture, pcm, end === 'no-speech' ? 'no-speech' : 'silence', vad);
|
|
706
|
-
this.
|
|
707
|
-
this.status('idle');
|
|
825
|
+
this.endWithoutRequest();
|
|
708
826
|
return;
|
|
709
827
|
}
|
|
710
828
|
this.status('processing');
|
|
711
829
|
try {
|
|
712
830
|
const hints = this.opts.getHints ? await this.opts.getHints() : undefined;
|
|
831
|
+
if (!current()) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
713
834
|
const speech = (0, speechAudio_1.prepareForStt)(pcm, MIC_SAMPLE_RATE, { startMs: vad.speechStartMs, endLevel: vad.endLevel });
|
|
714
835
|
vad.stt = { fromMs: speech.fromMs, toMs: speech.toMs, gain: speech.gain };
|
|
715
836
|
this.opts.log.debug(`ESPHome ${this.deviceName}: speech recognition gets ${(speech.fromMs / 1000).toFixed(2)}–` +
|
|
@@ -717,16 +838,19 @@ class EsphomeConnection {
|
|
|
717
838
|
const sttStart = Date.now();
|
|
718
839
|
const text = (await this.opts.stt.transcribe(speech.pcm, MIC_SAMPLE_RATE, this.opts.language, hints)).trim();
|
|
719
840
|
const sttMs = Date.now() - sttStart;
|
|
720
|
-
|
|
841
|
+
if (!current()) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
721
844
|
if (!text) {
|
|
722
845
|
// Speech was heard, so the user waits for an answer: say why none comes. Typically a word in another
|
|
723
846
|
// language than the STT listens in (a German "Ja" to a Russian STT comes back empty).
|
|
724
847
|
this.opts.log.info(`Voice Q (${this.deviceName}): (empty) — speech recognition returned no text for ${seconds.toFixed(1)} s of audio`);
|
|
725
848
|
this.discard(capture, pcm, 'empty-stt', vad);
|
|
726
|
-
this.
|
|
727
|
-
this.
|
|
849
|
+
// No STT_END without text — like Home Assistant, which reports this case as an error only.
|
|
850
|
+
this.endWithoutRequest();
|
|
728
851
|
return;
|
|
729
852
|
}
|
|
853
|
+
this.event(EVENT.STT_END, { text });
|
|
730
854
|
this.event(EVENT.INTENT_START);
|
|
731
855
|
const answer = (await this.opts.answer(text, {
|
|
732
856
|
device: this.deviceName,
|
|
@@ -737,6 +861,9 @@ class EsphomeConnection {
|
|
|
737
861
|
// Numbers only, no audio: shows where a request was cut off without keeping recordings.
|
|
738
862
|
vad: { ...vad, levels: (0, captures_1.levelProfile)(pcm, MIC_SAMPLE_RATE) },
|
|
739
863
|
})).trim();
|
|
864
|
+
if (!current()) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
740
867
|
const followUp = !!answer && this.opts.expectsFollowUp?.(answer, this.deviceName) === true;
|
|
741
868
|
this.event(EVENT.INTENT_END, followUp ? { continue_conversation: '1' } : undefined);
|
|
742
869
|
if (!answer) {
|
|
@@ -746,11 +873,17 @@ class EsphomeConnection {
|
|
|
746
873
|
}
|
|
747
874
|
const ttsStart = Date.now();
|
|
748
875
|
const { pcm: ttsPcm, sampleRate } = await this.opts.tts.synthesize(answer, this.opts.language);
|
|
876
|
+
if (!current()) {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
749
879
|
this.opts.onSpoken?.(this.deviceName, {
|
|
750
880
|
ms: Date.now() - ttsStart,
|
|
751
881
|
audioMs: (ttsPcm.length / 2 / sampleRate) * 1000,
|
|
752
882
|
});
|
|
753
|
-
const url = this.publish(ttsPcm, sampleRate);
|
|
883
|
+
const url = await this.publish(ttsPcm, sampleRate);
|
|
884
|
+
if (!current()) {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
754
887
|
this.status('speaking');
|
|
755
888
|
this.event(EVENT.TTS_START, { text: answer });
|
|
756
889
|
this.event(EVENT.TTS_END, { url });
|
|
@@ -760,12 +893,26 @@ class EsphomeConnection {
|
|
|
760
893
|
catch (e) {
|
|
761
894
|
const reason = e.message;
|
|
762
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
|
+
}
|
|
763
899
|
// Without this the device stays ducked and its LED keeps spinning.
|
|
764
900
|
this.event(EVENT.ERROR, { code: 'pipeline-failed', message: reason });
|
|
765
901
|
this.event(EVENT.RUN_END);
|
|
766
902
|
this.status('idle');
|
|
767
903
|
}
|
|
768
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
|
+
}
|
|
769
916
|
/**
|
|
770
917
|
* Select which wake words listen on this device. Unknown ids are dropped and the list is trimmed
|
|
771
918
|
* to what the firmware allows, because the device answers a rejected list with silence rather than
|
|
@@ -835,18 +982,19 @@ class EsphomeConnection {
|
|
|
835
982
|
this.capture = null;
|
|
836
983
|
capture.closed = true;
|
|
837
984
|
clearTimeout(capture.timer);
|
|
985
|
+
clearInterval(capture.stallCheck);
|
|
838
986
|
}
|
|
839
987
|
// ── announcements ───────────────────────────────────────────────────────────────────────────────
|
|
840
988
|
/**
|
|
841
989
|
* Speak a ready PCM buffer on this device (timers, alarms, `tts.text`, jingles). With
|
|
842
990
|
* `startConversation` the microphone opens once the clip has played, to catch the answer to a question.
|
|
843
991
|
*/
|
|
844
|
-
announce(pcm, sampleRate, startConversation = false) {
|
|
992
|
+
async announce(pcm, sampleRate, startConversation = false) {
|
|
845
993
|
if (!this.online || !pcm.length) {
|
|
846
|
-
return
|
|
994
|
+
return;
|
|
847
995
|
}
|
|
848
996
|
const request = new esphomeProto_1.pb.VoiceAssistantAnnounceRequest();
|
|
849
|
-
request.setMediaId(this.publish(pcm, sampleRate));
|
|
997
|
+
request.setMediaId(await this.publish(pcm, sampleRate));
|
|
850
998
|
request.setText('');
|
|
851
999
|
request.setPreannounceMediaId('');
|
|
852
1000
|
// Needs feature flag 32 (START_CONVERSATION); on a device without it the clip still plays and
|
|
@@ -855,16 +1003,45 @@ class EsphomeConnection {
|
|
|
855
1003
|
this.status('speaking');
|
|
856
1004
|
this.send(request);
|
|
857
1005
|
this.armPlaybackTimeout();
|
|
858
|
-
|
|
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);
|
|
859
1014
|
}
|
|
860
1015
|
/**
|
|
861
|
-
* Publish PCM
|
|
1016
|
+
* Publish PCM on the media server, in the format the device plays, and build the URL it should fetch.
|
|
862
1017
|
*
|
|
863
1018
|
* The host matters: on a multi-homed ioBroker machine the device can usually only reach one of the
|
|
864
1019
|
* addresses, and the local end of *this* socket is by definition one it can talk to.
|
|
865
1020
|
*/
|
|
866
|
-
publish(pcm, sampleRate) {
|
|
867
|
-
|
|
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));
|
|
868
1045
|
}
|
|
869
1046
|
/** Absolute URL of a path on the media server, with a host this device can reach. */
|
|
870
1047
|
mediaUrl(path) {
|
|
@@ -886,6 +1063,15 @@ class EsphomeConnection {
|
|
|
886
1063
|
this.playbackTimer.unref();
|
|
887
1064
|
}
|
|
888
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
|
+
}
|
|
889
1075
|
const message = new esphomeProto_1.pb.VoiceAssistantEventResponse();
|
|
890
1076
|
message.setEventType(type);
|
|
891
1077
|
const items = [];
|