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
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LEGACY_FORMAT = exports.CONTENT_TYPES = void 0;
4
+ exports.chooseReplyFormat = chooseReplyFormat;
5
+ exports.resamplePcm = resamplePcm;
6
+ exports.monoToStereo = monoToStereo;
7
+ exports.encodeReply = encodeReply;
8
+ /**
9
+ * Which audio format an ESPHome satellite gets its spoken replies in.
10
+ *
11
+ * A media player announces the formats it takes (`ListEntitiesMediaPlayerResponse.supported_formats`, one per
12
+ * purpose: default playback and announcements). Home Assistant transcodes every reply to the announcement format,
13
+ * so that is the only one a firmware is tested with — and since ESPHome 2026.10 often the only one it can decode at
14
+ * all: the player is built with just the codecs its pipelines use. We do the same: the announcement format first,
15
+ * at its sample rate and channel count. A device that lists nothing (linux-voice-assistant, older firmware) keeps
16
+ * getting WAV as before.
17
+ */
18
+ const flac_1 = require("./flac");
19
+ const stt_1 = require("./stt");
20
+ /** Codecs written here, without any tool. */
21
+ const NATIVE = ['flac', 'wav'];
22
+ const EXTERNAL = ['mp3', 'opus'];
23
+ exports.CONTENT_TYPES = {
24
+ wav: 'audio/wav',
25
+ flac: 'audio/flac',
26
+ mp3: 'audio/mpeg',
27
+ // ESPHome takes Opus only as `audio/ogg` with an opus codecs parameter.
28
+ opus: 'audio/ogg; codecs=opus',
29
+ };
30
+ /** The format replies have always had: WAV at the rate of the TTS engine. */
31
+ exports.LEGACY_FORMAT = { codec: 'wav', sampleRate: 0, channels: 1, source: '' };
32
+ function describe(f) {
33
+ return `${f.format}${f.sampleRate ? ` ${f.sampleRate} Hz` : ''}${f.channels ? ` ×${f.channels}` : ''} (${f.purpose})`;
34
+ }
35
+ /**
36
+ * Pick the reply format from what the device's media players announced.
37
+ *
38
+ * @param formats every `supported_formats` entry of the device
39
+ * @param external whether MP3/Opus can be produced (ffmpeg)
40
+ */
41
+ function chooseReplyFormat(formats, external) {
42
+ if (!formats.length) {
43
+ return exports.LEGACY_FORMAT;
44
+ }
45
+ const usable = (f) => {
46
+ const codec = f.format.trim().toLowerCase();
47
+ return NATIVE.includes(codec) || (external && EXTERNAL.includes(codec)) ? codec : null;
48
+ };
49
+ // Announcements first (that is how a reply is played), our own codecs before ffmpeg ones.
50
+ const ordered = [
51
+ ...formats.filter(f => f.purpose === 'announcement'),
52
+ ...formats.filter(f => f.purpose !== 'announcement'),
53
+ ];
54
+ const pick = ordered.find(f => {
55
+ const codec = usable(f);
56
+ return codec !== null && NATIVE.includes(codec);
57
+ }) ?? ordered.find(f => usable(f) !== null);
58
+ if (!pick) {
59
+ // Nothing we can write (or `none` = the device takes anything): WAV, as before.
60
+ return { ...exports.LEGACY_FORMAT, source: ordered.map(describe).join(', ') };
61
+ }
62
+ return {
63
+ codec: usable(pick),
64
+ sampleRate: pick.sampleRate || 0,
65
+ channels: pick.channels === 2 ? 2 : 1,
66
+ source: describe(pick),
67
+ };
68
+ }
69
+ /** Filter taps per side at the lower of the two rates, and the number of precomputed sub-sample positions. */
70
+ const ZERO_CROSSINGS = 8;
71
+ const PHASES = 128;
72
+ /**
73
+ * Resample mono s16le PCM with a Hann-windowed sinc. Cheap enough for the voice path (a table per sub-sample
74
+ * position, ~20 taps when upsampling), and — unlike linear interpolation — it filters before it decimates, so a
75
+ * 24 kHz voice taken down to 16 kHz does not fold its sibilants back into the speech band.
76
+ */
77
+ function resamplePcm(pcm, from, to) {
78
+ const n = pcm.length >> 1;
79
+ if (from === to || !n || from <= 0 || to <= 0) {
80
+ return pcm;
81
+ }
82
+ const input = new Float32Array(n);
83
+ for (let i = 0; i < n; i++) {
84
+ input[i] = pcm.readInt16LE(i * 2);
85
+ }
86
+ const ratio = to / from;
87
+ // A little below the Nyquist frequency of the lower rate.
88
+ const cutoff = Math.min(1, ratio) * 0.95;
89
+ const half = Math.ceil(ZERO_CROSSINGS / cutoff);
90
+ const taps = 2 * half;
91
+ const table = new Float32Array((PHASES + 1) * taps);
92
+ for (let p = 0; p <= PHASES; p++) {
93
+ const frac = p / PHASES;
94
+ let sum = 0;
95
+ for (let k = 0; k < taps; k++) {
96
+ const d = k - half + 1 - frac; // distance of the input sample from the output position
97
+ const x = d * cutoff;
98
+ const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
99
+ const w = Math.abs(d) >= half ? 0 : 0.5 + 0.5 * Math.cos((Math.PI * d) / half);
100
+ table[p * taps + k] = sinc * w;
101
+ sum += sinc * w;
102
+ }
103
+ for (let k = 0; k < taps; k++) {
104
+ table[p * taps + k] /= sum; // unity gain at every position: no ripple on a constant level
105
+ }
106
+ }
107
+ const outLen = Math.max(1, Math.round(n * ratio));
108
+ const out = Buffer.alloc(outLen * 2);
109
+ for (let i = 0; i < outLen; i++) {
110
+ const center = i / ratio;
111
+ const base = Math.floor(center);
112
+ const row = Math.round((center - base) * PHASES) * taps;
113
+ const first = base - half + 1;
114
+ let acc = 0;
115
+ for (let k = 0; k < taps; k++) {
116
+ const j = first + k;
117
+ if (j >= 0 && j < n) {
118
+ acc += input[j] * table[row + k];
119
+ }
120
+ }
121
+ out.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(acc))), i * 2);
122
+ }
123
+ return out;
124
+ }
125
+ /** Duplicate mono s16le PCM into interleaved stereo. */
126
+ function monoToStereo(pcm) {
127
+ const n = pcm.length >> 1;
128
+ const out = Buffer.alloc(n * 4);
129
+ for (let i = 0; i < n; i++) {
130
+ const v = pcm.readInt16LE(i * 2);
131
+ out.writeInt16LE(v, i * 4);
132
+ out.writeInt16LE(v, i * 4 + 2);
133
+ }
134
+ return out;
135
+ }
136
+ /**
137
+ * Encode a mono reply in `format`. MP3/Opus go through `external`; everything else is written here.
138
+ * Throws when `external` is needed but missing or fails — the caller falls back to a native format.
139
+ */
140
+ async function encodeReply(pcm, sampleRate, format, external) {
141
+ const rate = format.sampleRate || sampleRate;
142
+ if (format.codec === 'mp3' || format.codec === 'opus') {
143
+ if (!external) {
144
+ throw new Error(`no encoder for ${format.codec}`);
145
+ }
146
+ const body = await external(pcm, sampleRate, {
147
+ codec: format.codec,
148
+ sampleRate: rate,
149
+ channels: format.channels,
150
+ });
151
+ return { body, contentType: exports.CONTENT_TYPES[format.codec] };
152
+ }
153
+ const resampled = resamplePcm(pcm, sampleRate, rate);
154
+ const samples = format.channels === 2 ? monoToStereo(resampled) : resampled;
155
+ const body = format.codec === 'flac' ? (0, flac_1.encodeFlac)(samples, rate, format.channels) : (0, stt_1.pcmToWav)(samples, rate, format.channels);
156
+ return { body, contentType: exports.CONTENT_TYPES[format.codec] };
157
+ }
158
+ //# sourceMappingURL=replyFormat.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replyFormat.js","sourceRoot":"","sources":["../../../src/lib/voice/replyFormat.ts"],"names":[],"mappings":";;;AAoEA,8CA4BC;AAWD,kCA+CC;AAGD,oCASC;AAMD,kCAuBC;AAnMD;;;;;;;;;GASG;AACH,iCAAoC;AACpC,+BAAiC;AAgCjC,6CAA6C;AAC7C,MAAM,MAAM,GAAiB,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAC7C,MAAM,QAAQ,GAAiB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;AAElC,QAAA,aAAa,GAA+B;IACrD,GAAG,EAAE,WAAW;IAChB,IAAI,EAAE,YAAY;IAClB,GAAG,EAAE,YAAY;IACjB,wEAAwE;IACxE,IAAI,EAAE,wBAAwB;CACjC,CAAC;AAEF,6EAA6E;AAChE,QAAA,aAAa,GAAgB,EAAE,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;AAEnG,SAAS,QAAQ,CAAC,CAAc;IAC5B,OAAO,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,UAAU,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,OAAO,GAAG,CAAC;AAC1H,CAAC;AAED;;;;;GAKG;AACH,SAAgB,iBAAiB,CAAC,OAAsB,EAAE,QAAiB;IACvE,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;QAClB,OAAO,qBAAa,CAAC;IACzB,CAAC;IACD,MAAM,MAAM,GAAG,CAAC,CAAc,EAAqB,EAAE;QACjD,MAAM,KAAK,GAAG,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,WAAW,EAAgB,CAAC;QAC1D,OAAO,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;IAC3F,CAAC,CAAC;IACF,0FAA0F;IAC1F,MAAM,OAAO,GAAG;QACZ,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,cAAc,CAAC;QACpD,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,cAAc,CAAC;KACvD,CAAC;IACF,MAAM,IAAI,GACN,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE;QACb,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QACxB,OAAO,KAAK,KAAK,IAAI,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;IACpD,CAAC,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC;IAChD,IAAI,CAAC,IAAI,EAAE,CAAC;QACR,gFAAgF;QAChF,OAAO,EAAE,GAAG,qBAAa,EAAE,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;IAC1E,CAAC;IACD,OAAO;QACH,KAAK,EAAE,MAAM,CAAC,IAAI,CAAe;QACjC,UAAU,EAAE,IAAI,CAAC,UAAU,IAAI,CAAC;QAChC,QAAQ,EAAE,IAAI,CAAC,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACrC,MAAM,EAAE,QAAQ,CAAC,IAAI,CAAC;KACzB,CAAC;AACN,CAAC;AAED,8GAA8G;AAC9G,MAAM,cAAc,GAAG,CAAC,CAAC;AACzB,MAAM,MAAM,GAAG,GAAG,CAAC;AAEnB;;;;GAIG;AACH,SAAgB,WAAW,CAAC,GAAW,EAAE,IAAY,EAAE,EAAU;IAC7D,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;IAC1B,IAAI,IAAI,KAAK,EAAE,IAAI,CAAC,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,CAAC;QAC5C,OAAO,GAAG,CAAC;IACf,CAAC;IACD,MAAM,KAAK,GAAG,IAAI,YAAY,CAAC,CAAC,CAAC,CAAC;IAClC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QACzB,KAAK,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,WAAW,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IACtC,CAAC;IACD,MAAM,KAAK,GAAG,EAAE,GAAG,IAAI,CAAC;IACxB,0DAA0D;IAC1D,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,GAAG,IAAI,CAAC;IACzC,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,cAAc,GAAG,MAAM,CAAC,CAAC;IAChD,MAAM,IAAI,GAAG,CAAC,GAAG,IAAI,CAAC;IACtB,MAAM,KAAK,GAAG,IAAI,YAAY,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACpD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAC/B,MAAM,IAAI,GAAG,CAAC,GAAG,MAAM,CAAC;QACxB,IAAI,GAAG,GAAG,CAAC,CAAC;QACZ,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;YAC5B,MAAM,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC,wDAAwD;YACvF,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC;YACrB,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;YACjE,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;YAC/E,KAAK,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC;YAC/B,GAAG,IAAI,IAAI,GAAG,CAAC,CAAC;QACpB,CAAC;QACD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;YAC5B,KAAK,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,8DAA8D;QAC9F,CAAC;IACL,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC;IAClD,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IACrC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAC9B,MAAM,MAAM,GAAG,CAAC,GAAG,KAAK,CAAC;QACzB,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QAChC,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC;QACxD,MAAM,KAAK,GAAG,IAAI,GAAG,IAAI,GAAG,CAAC,CAAC;QAC9B,IAAI,GAAG,GAAG,CAAC,CAAC;QACZ,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;YAC5B,MAAM,CAAC,GAAG,KAAK,GAAG,CAAC,CAAC;YACpB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;gBAClB,GAAG,IAAI,KAAK,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;YACrC,CAAC;QACL,CAAC;QACD,GAAG,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAChF,CAAC;IACD,OAAO,GAAG,CAAC;AACf,CAAC;AAED,wDAAwD;AACxD,SAAgB,YAAY,CAAC,GAAW;IACpC,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,IAAI,CAAC,CAAC;IAC1B,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAChC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QACzB,MAAM,CAAC,GAAG,GAAG,CAAC,WAAW,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QACjC,GAAG,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;QAC3B,GAAG,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IACnC,CAAC;IACD,OAAO,GAAG,CAAC;AACf,CAAC;AAED;;;GAGG;AACI,KAAK,UAAU,WAAW,CAC7B,GAAW,EACX,UAAkB,EAClB,MAAmB,EACnB,QAA0B;IAE1B,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,IAAI,UAAU,CAAC;IAC7C,IAAI,MAAM,CAAC,KAAK,KAAK,KAAK,IAAI,MAAM,CAAC,KAAK,KAAK,MAAM,EAAE,CAAC;QACpD,IAAI,CAAC,QAAQ,EAAE,CAAC;YACZ,MAAM,IAAI,KAAK,CAAC,kBAAkB,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;QACtD,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,GAAG,EAAE,UAAU,EAAE;YACzC,KAAK,EAAE,MAAM,CAAC,KAAK;YACnB,UAAU,EAAE,IAAI;YAChB,QAAQ,EAAE,MAAM,CAAC,QAAQ;SAC5B,CAAC,CAAC;QACH,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,qBAAa,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IAC9D,CAAC;IACD,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,CAAC,CAAC;IACrD,MAAM,OAAO,GAAG,MAAM,CAAC,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC5E,MAAM,IAAI,GACN,MAAM,CAAC,KAAK,KAAK,MAAM,CAAC,CAAC,CAAC,IAAA,iBAAU,EAAC,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAA,cAAQ,EAAC,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;IACpH,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,qBAAa,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;AAC9D,CAAC","sourcesContent":["/**\n * Which audio format an ESPHome satellite gets its spoken replies in.\n *\n * A media player announces the formats it takes (`ListEntitiesMediaPlayerResponse.supported_formats`, one per\n * purpose: default playback and announcements). Home Assistant transcodes every reply to the announcement format,\n * so that is the only one a firmware is tested with — and since ESPHome 2026.10 often the only one it can decode at\n * all: the player is built with just the codecs its pipelines use. We do the same: the announcement format first,\n * at its sample rate and channel count. A device that lists nothing (linux-voice-assistant, older firmware) keeps\n * getting WAV as before.\n */\nimport { encodeFlac } from './flac';\nimport { pcmToWav } from './stt';\n\n/** One entry of `supported_formats`. */\nexport interface MediaFormat {\n /** `flac`, `mp3`, `wav`, `opus`, `none` … as the device names it. */\n format: string;\n /** 0 = no preference. */\n sampleRate: number;\n /** 0 = no preference. */\n channels: number;\n purpose: 'default' | 'announcement';\n sampleBytes: number;\n}\n\nexport type ReplyCodec = 'wav' | 'flac' | 'mp3' | 'opus';\n\nexport interface ReplyFormat {\n codec: ReplyCodec;\n /** Target rate; 0 keeps the rate of the TTS engine. */\n sampleRate: number;\n channels: 1 | 2;\n /** What the device asked for, for the log. */\n source: string;\n}\n\n/** Turns PCM into a codec we cannot write ourselves (MP3, Opus) — ffmpeg in the adapter. */\nexport type ExternalEncoder = (\n pcm: Buffer,\n sampleRate: number,\n target: { codec: 'mp3' | 'opus'; sampleRate: number; channels: number },\n) => Promise<Buffer>;\n\n/** Codecs written here, without any tool. */\nconst NATIVE: ReplyCodec[] = ['flac', 'wav'];\nconst EXTERNAL: ReplyCodec[] = ['mp3', 'opus'];\n\nexport const CONTENT_TYPES: Record<ReplyCodec, string> = {\n wav: 'audio/wav',\n flac: 'audio/flac',\n mp3: 'audio/mpeg',\n // ESPHome takes Opus only as `audio/ogg` with an opus codecs parameter.\n opus: 'audio/ogg; codecs=opus',\n};\n\n/** The format replies have always had: WAV at the rate of the TTS engine. */\nexport const LEGACY_FORMAT: ReplyFormat = { codec: 'wav', sampleRate: 0, channels: 1, source: '' };\n\nfunction describe(f: MediaFormat): string {\n return `${f.format}${f.sampleRate ? ` ${f.sampleRate} Hz` : ''}${f.channels ? ` ×${f.channels}` : ''} (${f.purpose})`;\n}\n\n/**\n * Pick the reply format from what the device's media players announced.\n *\n * @param formats every `supported_formats` entry of the device\n * @param external whether MP3/Opus can be produced (ffmpeg)\n */\nexport function chooseReplyFormat(formats: MediaFormat[], external: boolean): ReplyFormat {\n if (!formats.length) {\n return LEGACY_FORMAT;\n }\n const usable = (f: MediaFormat): ReplyCodec | null => {\n const codec = f.format.trim().toLowerCase() as ReplyCodec;\n return NATIVE.includes(codec) || (external && EXTERNAL.includes(codec)) ? codec : null;\n };\n // Announcements first (that is how a reply is played), our own codecs before ffmpeg ones.\n const ordered = [\n ...formats.filter(f => f.purpose === 'announcement'),\n ...formats.filter(f => f.purpose !== 'announcement'),\n ];\n const pick =\n ordered.find(f => {\n const codec = usable(f);\n return codec !== null && NATIVE.includes(codec);\n }) ?? ordered.find(f => usable(f) !== null);\n if (!pick) {\n // Nothing we can write (or `none` = the device takes anything): WAV, as before.\n return { ...LEGACY_FORMAT, source: ordered.map(describe).join(', ') };\n }\n return {\n codec: usable(pick) as ReplyCodec,\n sampleRate: pick.sampleRate || 0,\n channels: pick.channels === 2 ? 2 : 1,\n source: describe(pick),\n };\n}\n\n/** Filter taps per side at the lower of the two rates, and the number of precomputed sub-sample positions. */\nconst ZERO_CROSSINGS = 8;\nconst PHASES = 128;\n\n/**\n * Resample mono s16le PCM with a Hann-windowed sinc. Cheap enough for the voice path (a table per sub-sample\n * position, ~20 taps when upsampling), and — unlike linear interpolation — it filters before it decimates, so a\n * 24 kHz voice taken down to 16 kHz does not fold its sibilants back into the speech band.\n */\nexport function resamplePcm(pcm: Buffer, from: number, to: number): Buffer {\n const n = pcm.length >> 1;\n if (from === to || !n || from <= 0 || to <= 0) {\n return pcm;\n }\n const input = new Float32Array(n);\n for (let i = 0; i < n; i++) {\n input[i] = pcm.readInt16LE(i * 2);\n }\n const ratio = to / from;\n // A little below the Nyquist frequency of the lower rate.\n const cutoff = Math.min(1, ratio) * 0.95;\n const half = Math.ceil(ZERO_CROSSINGS / cutoff);\n const taps = 2 * half;\n const table = new Float32Array((PHASES + 1) * taps);\n for (let p = 0; p <= PHASES; p++) {\n const frac = p / PHASES;\n let sum = 0;\n for (let k = 0; k < taps; k++) {\n const d = k - half + 1 - frac; // distance of the input sample from the output position\n const x = d * cutoff;\n const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);\n const w = Math.abs(d) >= half ? 0 : 0.5 + 0.5 * Math.cos((Math.PI * d) / half);\n table[p * taps + k] = sinc * w;\n sum += sinc * w;\n }\n for (let k = 0; k < taps; k++) {\n table[p * taps + k] /= sum; // unity gain at every position: no ripple on a constant level\n }\n }\n const outLen = Math.max(1, Math.round(n * ratio));\n const out = Buffer.alloc(outLen * 2);\n for (let i = 0; i < outLen; i++) {\n const center = i / ratio;\n const base = Math.floor(center);\n const row = Math.round((center - base) * PHASES) * taps;\n const first = base - half + 1;\n let acc = 0;\n for (let k = 0; k < taps; k++) {\n const j = first + k;\n if (j >= 0 && j < n) {\n acc += input[j] * table[row + k];\n }\n }\n out.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(acc))), i * 2);\n }\n return out;\n}\n\n/** Duplicate mono s16le PCM into interleaved stereo. */\nexport function monoToStereo(pcm: Buffer): Buffer {\n const n = pcm.length >> 1;\n const out = Buffer.alloc(n * 4);\n for (let i = 0; i < n; i++) {\n const v = pcm.readInt16LE(i * 2);\n out.writeInt16LE(v, i * 4);\n out.writeInt16LE(v, i * 4 + 2);\n }\n return out;\n}\n\n/**\n * Encode a mono reply in `format`. MP3/Opus go through `external`; everything else is written here.\n * Throws when `external` is needed but missing or fails — the caller falls back to a native format.\n */\nexport async function encodeReply(\n pcm: Buffer,\n sampleRate: number,\n format: ReplyFormat,\n external?: ExternalEncoder,\n): Promise<{ body: Buffer; contentType: string }> {\n const rate = format.sampleRate || sampleRate;\n if (format.codec === 'mp3' || format.codec === 'opus') {\n if (!external) {\n throw new Error(`no encoder for ${format.codec}`);\n }\n const body = await external(pcm, sampleRate, {\n codec: format.codec,\n sampleRate: rate,\n channels: format.channels,\n });\n return { body, contentType: CONTENT_TYPES[format.codec] };\n }\n const resampled = resamplePcm(pcm, sampleRate, rate);\n const samples = format.channels === 2 ? monoToStereo(resampled) : resampled;\n const body =\n format.codec === 'flac' ? encodeFlac(samples, rate, format.channels) : pcmToWav(samples, rate, format.channels);\n return { body, contentType: CONTENT_TYPES[format.codec] };\n}\n"]}
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.OpenAiStt = void 0;
37
37
  exports.hintsToPrompt = hintsToPrompt;
38
38
  exports.pcmToWav = pcmToWav;
39
+ exports.normalizeSttBaseUrl = normalizeSttBaseUrl;
39
40
  /**
40
41
  * Speech-to-text engines. V1 ships the cloud engine (OpenAI Whisper); local engines
41
42
  * (Vosk / whisper.cpp / Wyoming) plug in behind the same `SttEngine` interface later.
@@ -67,9 +68,8 @@ function hintsToPrompt(hints, maxChars = 800) {
67
68
  }
68
69
  return parts.join(', ');
69
70
  }
70
- /** Wrap raw mono 16-bit-signed-LE PCM into a minimal 44-byte WAV container. */
71
- function pcmToWav(pcm, sampleRate) {
72
- const numChannels = 1;
71
+ /** Wrap raw 16-bit-signed-LE PCM (mono, or interleaved stereo) into a minimal 44-byte WAV container. */
72
+ function pcmToWav(pcm, sampleRate, numChannels = 1) {
73
73
  const bitsPerSample = 16;
74
74
  const blockAlign = (numChannels * bitsPerSample) / 8;
75
75
  const byteRate = sampleRate * blockAlign;
@@ -89,12 +89,43 @@ function pcmToWav(pcm, sampleRate) {
89
89
  header.writeUInt32LE(pcm.length, 40);
90
90
  return Buffer.concat([header, pcm]);
91
91
  }
92
- /** Cloud STT via the OpenAI transcription API (Whisper). */
92
+ /**
93
+ * Base URL of an OpenAI-compatible transcription server as the SDK wants it. Practically every such server
94
+ * (speaches/faster-whisper-server, LocalAI, vLLM, Groq) serves `/v1/audio/transcriptions`, so an address typed
95
+ * without a path gets `/v1`; one with a path is taken as it is.
96
+ */
97
+ function normalizeSttBaseUrl(raw) {
98
+ const url = raw.trim().replace(/\/+$/, '');
99
+ if (!url) {
100
+ return '';
101
+ }
102
+ try {
103
+ const parsed = new URL(url);
104
+ return parsed.pathname === '/' || parsed.pathname === '' ? `${url}/v1` : url;
105
+ }
106
+ catch {
107
+ throw new Error(`speech recognition base URL "${raw}" is not a URL (e.g. http://192.168.1.10:8000/v1)`);
108
+ }
109
+ }
110
+ /**
111
+ * STT via the OpenAI transcription API (Whisper) — or any server that speaks it, e.g. a local
112
+ * whisper-large-v3-turbo next to ioBroker (`baseUrl`).
113
+ */
93
114
  class OpenAiStt {
94
115
  client;
95
116
  model;
96
- constructor(apiKey, model = 'whisper-1') {
97
- this.client = new openai_1.default({ apiKey });
117
+ /**
118
+ * @param apiKey OpenAI key, or the key of the server at `baseUrl` ('' for a server without one)
119
+ * @param model model id; on an own server as that server names it
120
+ * @param baseUrl OpenAI-compatible server instead of api.openai.com ('' = OpenAI)
121
+ */
122
+ constructor(apiKey, model = 'whisper-1', baseUrl = '') {
123
+ const baseURL = normalizeSttBaseUrl(baseUrl);
124
+ this.client = baseURL
125
+ ? // The SDK refuses an empty key; a local server ignores it. A server that hangs must not hold the
126
+ // satellite for the SDK's default ten minutes.
127
+ new openai_1.default({ apiKey: apiKey || 'none', baseURL, timeout: 60_000, maxRetries: 1 })
128
+ : new openai_1.default({ apiKey });
98
129
  this.model = model;
99
130
  }
100
131
  async transcribe(pcm, sampleRate, lang, hints) {
@@ -1 +1 @@
1
- {"version":3,"file":"stt.js","sourceRoot":"","sources":["../../../src/lib/voice/stt.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Speech-to-text engines. V1 ships the cloud engine (OpenAI Whisper); local engines\n * (Vosk / whisper.cpp / Wyoming) plug in behind the same `SttEngine` interface later.\n */\nimport OpenAI, { toFile } from 'openai';\n\nexport interface SttEngine {\n /**\n * Transcribe raw mono 16-bit PCM. `lang` is an ISO-639-1 hint ('' = auto-detect).\n *\n * `hints` is an optional domain vocabulary (device / room / scene names …) to bias recognition toward,\n * so the engine better transcribes those proper nouns. Only engines that support *soft* biasing use it\n * (OpenAI Whisper `prompt`, Azure phrase list); engines where a word list would be a hard constraint or\n * needs pre-registration (Vosk grammar, AWS custom vocabulary) ignore it.\n */\n transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string>;\n /** Optional warm-up (install/download engine + model for `lang`) so the first request isn't slow. */\n prepare?(lang: string): Promise<void>;\n}\n\n/**\n * Join vocabulary hints into a Whisper `prompt` string (a soft bias toward these terms), de-duplicated and\n * length-bounded to stay within the model's ~224-token prompt budget. Returns '' when there are no hints.\n */\nexport function hintsToPrompt(hints: string[] | undefined, maxChars = 800): string {\n if (!hints?.length) {\n return '';\n }\n const seen = new Set<string>();\n const parts: string[] = [];\n let len = 0;\n for (const h of hints) {\n const term = (h || '').trim();\n const key = term.toLowerCase();\n if (!term || seen.has(key)) {\n continue;\n }\n if (len + term.length + 2 > maxChars) {\n break;\n }\n seen.add(key);\n parts.push(term);\n len += term.length + 2;\n }\n return parts.join(', ');\n}\n\n/** Wrap raw mono 16-bit-signed-LE PCM into a minimal 44-byte WAV container. */\nexport function pcmToWav(pcm: Buffer, sampleRate: number): Buffer {\n const numChannels = 1;\n const bitsPerSample = 16;\n const blockAlign = (numChannels * bitsPerSample) / 8;\n const byteRate = sampleRate * blockAlign;\n const header = Buffer.alloc(44);\n header.write('RIFF', 0);\n header.writeUInt32LE(36 + pcm.length, 4);\n header.write('WAVE', 8);\n header.write('fmt ', 12);\n header.writeUInt32LE(16, 16); // PCM fmt chunk size\n header.writeUInt16LE(1, 20); // audio format = PCM\n header.writeUInt16LE(numChannels, 22);\n header.writeUInt32LE(sampleRate, 24);\n header.writeUInt32LE(byteRate, 28);\n header.writeUInt16LE(blockAlign, 32);\n header.writeUInt16LE(bitsPerSample, 34);\n header.write('data', 36);\n header.writeUInt32LE(pcm.length, 40);\n return Buffer.concat([header, pcm]);\n}\n\n/** Cloud STT via the OpenAI transcription API (Whisper). */\nexport class OpenAiStt implements SttEngine {\n private readonly client: OpenAI;\n private readonly model: string;\n\n constructor(apiKey: string, model = 'whisper-1') {\n this.client = new OpenAI({ apiKey });\n this.model = model;\n }\n\n async transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string> {\n const file = await toFile(pcmToWav(pcm, sampleRate), 'audio.wav', { type: 'audio/wav' });\n // A prompt biases Whisper toward the given domain vocabulary (device/room names) without excluding\n // other words — improves recognition of proper nouns the NLU then needs to match.\n const prompt = hintsToPrompt(hints);\n const res = await this.client.audio.transcriptions.create({\n file,\n model: this.model,\n // Whisper wants the bare ISO-639-1 code; strip any region (de-DE → de). '' → auto-detect.\n language: (lang || '').split('-')[0] || undefined,\n ...(prompt ? { prompt } : {}),\n });\n return (res.text || '').trim();\n }\n}\n"]}
1
+ {"version":3,"file":"stt.js","sourceRoot":"","sources":["../../../src/lib/voice/stt.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Speech-to-text engines. V1 ships the cloud engine (OpenAI Whisper); local engines\n * (Vosk / whisper.cpp / Wyoming) plug in behind the same `SttEngine` interface later.\n */\nimport OpenAI, { toFile } from 'openai';\n\nexport interface SttEngine {\n /**\n * Transcribe raw mono 16-bit PCM. `lang` is an ISO-639-1 hint ('' = auto-detect).\n *\n * `hints` is an optional domain vocabulary (device / room / scene names …) to bias recognition toward,\n * so the engine better transcribes those proper nouns. Only engines that support *soft* biasing use it\n * (OpenAI Whisper `prompt`, Azure phrase list); engines where a word list would be a hard constraint or\n * needs pre-registration (Vosk grammar, AWS custom vocabulary) ignore it.\n */\n transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string>;\n /** Optional warm-up (install/download engine + model for `lang`) so the first request isn't slow. */\n prepare?(lang: string): Promise<void>;\n}\n\n/**\n * Join vocabulary hints into a Whisper `prompt` string (a soft bias toward these terms), de-duplicated and\n * length-bounded to stay within the model's ~224-token prompt budget. Returns '' when there are no hints.\n */\nexport function hintsToPrompt(hints: string[] | undefined, maxChars = 800): string {\n if (!hints?.length) {\n return '';\n }\n const seen = new Set<string>();\n const parts: string[] = [];\n let len = 0;\n for (const h of hints) {\n const term = (h || '').trim();\n const key = term.toLowerCase();\n if (!term || seen.has(key)) {\n continue;\n }\n if (len + term.length + 2 > maxChars) {\n break;\n }\n seen.add(key);\n parts.push(term);\n len += term.length + 2;\n }\n return parts.join(', ');\n}\n\n/** Wrap raw 16-bit-signed-LE PCM (mono, or interleaved stereo) into a minimal 44-byte WAV container. */\nexport function pcmToWav(pcm: Buffer, sampleRate: number, numChannels = 1): Buffer {\n const bitsPerSample = 16;\n const blockAlign = (numChannels * bitsPerSample) / 8;\n const byteRate = sampleRate * blockAlign;\n const header = Buffer.alloc(44);\n header.write('RIFF', 0);\n header.writeUInt32LE(36 + pcm.length, 4);\n header.write('WAVE', 8);\n header.write('fmt ', 12);\n header.writeUInt32LE(16, 16); // PCM fmt chunk size\n header.writeUInt16LE(1, 20); // audio format = PCM\n header.writeUInt16LE(numChannels, 22);\n header.writeUInt32LE(sampleRate, 24);\n header.writeUInt32LE(byteRate, 28);\n header.writeUInt16LE(blockAlign, 32);\n header.writeUInt16LE(bitsPerSample, 34);\n header.write('data', 36);\n header.writeUInt32LE(pcm.length, 40);\n return Buffer.concat([header, pcm]);\n}\n\n/**\n * Base URL of an OpenAI-compatible transcription server as the SDK wants it. Practically every such server\n * (speaches/faster-whisper-server, LocalAI, vLLM, Groq) serves `/v1/audio/transcriptions`, so an address typed\n * without a path gets `/v1`; one with a path is taken as it is.\n */\nexport function normalizeSttBaseUrl(raw: string): string {\n const url = raw.trim().replace(/\\/+$/, '');\n if (!url) {\n return '';\n }\n try {\n const parsed = new URL(url);\n return parsed.pathname === '/' || parsed.pathname === '' ? `${url}/v1` : url;\n } catch {\n throw new Error(`speech recognition base URL \"${raw}\" is not a URL (e.g. http://192.168.1.10:8000/v1)`);\n }\n}\n\n/**\n * STT via the OpenAI transcription API (Whisper) — or any server that speaks it, e.g. a local\n * whisper-large-v3-turbo next to ioBroker (`baseUrl`).\n */\nexport class OpenAiStt implements SttEngine {\n private readonly client: OpenAI;\n private readonly model: string;\n\n /**\n * @param apiKey OpenAI key, or the key of the server at `baseUrl` ('' for a server without one)\n * @param model model id; on an own server as that server names it\n * @param baseUrl OpenAI-compatible server instead of api.openai.com ('' = OpenAI)\n */\n constructor(apiKey: string, model = 'whisper-1', baseUrl = '') {\n const baseURL = normalizeSttBaseUrl(baseUrl);\n this.client = baseURL\n ? // The SDK refuses an empty key; a local server ignores it. A server that hangs must not hold the\n // satellite for the SDK's default ten minutes.\n new OpenAI({ apiKey: apiKey || 'none', baseURL, timeout: 60_000, maxRetries: 1 })\n : new OpenAI({ apiKey });\n this.model = model;\n }\n\n async transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string> {\n const file = await toFile(pcmToWav(pcm, sampleRate), 'audio.wav', { type: 'audio/wav' });\n // A prompt biases Whisper toward the given domain vocabulary (device/room names) without excluding\n // other words — improves recognition of proper nouns the NLU then needs to match.\n const prompt = hintsToPrompt(hints);\n const res = await this.client.audio.transcriptions.create({\n file,\n model: this.model,\n // Whisper wants the bare ISO-639-1 code; strip any region (de-DE → de). '' → auto-detect.\n language: (lang || '').split('-')[0] || undefined,\n ...(prompt ? { prompt } : {}),\n });\n return (res.text || '').trim();\n }\n}\n"]}
package/build/main.js CHANGED
@@ -107,6 +107,8 @@ const VOICE_FORM_FIELDS = [
107
107
  'voiceLanguage',
108
108
  'sttProvider',
109
109
  'sttModel',
110
+ 'sttBaseUrl',
111
+ 'sttServerKey',
110
112
  'openrouterSttModel',
111
113
  'voskModel',
112
114
  'ttsProvider',
@@ -131,7 +133,13 @@ const VOICE_FORM_FIELDS = [
131
133
  'piperVoice',
132
134
  ];
133
135
  /** Of those, the protected ones: the browser never gets them, so an empty value there means "keep the saved one" */
134
- const VOICE_FORM_SECRETS = ['voiceApiKey', 'openrouterSpeechKey', 'azureSpeechKey', 'awsSecretAccessKey'];
136
+ const VOICE_FORM_SECRETS = [
137
+ 'voiceApiKey',
138
+ 'openrouterSpeechKey',
139
+ 'azureSpeechKey',
140
+ 'awsSecretAccessKey',
141
+ 'sttServerKey',
142
+ ];
135
143
  /** An empty `ask` from the same caller is reported at most this often. */
136
144
  const EMPTY_ASK_REPORT_MS = 60_000;
137
145
  /** How long to wait for a device to confirm a write, and how often to look. */
@@ -162,6 +170,10 @@ function valuesMatch(actual, expected) {
162
170
  }
163
171
  return String(actual).trim().toLowerCase() === String(expected).trim().toLowerCase();
164
172
  }
173
+ /** Whether an ESPHome entity id can be the last part of an ioBroker id (empty ones came from ESPHome 2026). */
174
+ function isControlId(objectId) {
175
+ return !!objectId && !/[^\p{Ll}\p{Lu}\p{Nd}_-]/u.test(objectId);
176
+ }
165
177
  /**
166
178
  * ioBroker role for a measurement, derived from its unit — a `value.temperature` shows up with the right
167
179
  * icon and in the right place in vis, where a bare `value` is just a number.
@@ -334,6 +346,8 @@ class Assistant extends adapter_core_1.Adapter {
334
346
  satControlsReady = new Map();
335
347
  /** Satellites whose `volume` state exists and is subscribed (see updateSatelliteVolume). */
336
348
  satVolumeEnsured = new Set();
349
+ /** ESPHome satellite id → the table address already written to its object (see markEsphomeSatellite). */
350
+ satEsphomeMarked = new Map();
337
351
  /** Native (ioBroker) satellite state-id → sender instance id, so we can push announcements back to it. */
338
352
  nativeSatFrom = new Map();
339
353
  /** Countdown timers / reminders (roadmap #2); mirrored into `timers.*` states. Null until onReady. */
@@ -588,6 +602,8 @@ class Assistant extends adapter_core_1.Adapter {
588
602
  async startEsphome(llmKey) {
589
603
  const cfg = this.config;
590
604
  const devices = (cfg.esphomeDevices || []).filter(d => d?.ip?.trim());
605
+ // Rows deleted from the table leave their satellite objects behind otherwise — forever offline in the list.
606
+ await this.removeStaleEsphomeSatellites(devices).catch(e => this.log.warn(`Could not clean up removed ESPHome satellites: ${e.message}`));
591
607
  if (!devices.length) {
592
608
  this.log.warn('ESPHome satellites are enabled, but no device is configured.');
593
609
  return;
@@ -643,9 +659,12 @@ class Assistant extends adapter_core_1.Adapter {
643
659
  mediaHost: (cfg.esphomeMediaHost || '').trim(),
644
660
  silenceMs: cfg.esphomeSilenceMs || 0,
645
661
  log: this.log,
646
- onStatus: (device, room, state) => {
647
- this.updateSatelliteState(device, room, state).catch(e => this.log.debug(`satellite state update failed: ${e.message}`));
662
+ onStatus: (device, room, state, address) => {
663
+ this.updateSatelliteState(device, room, state)
664
+ .then(() => this.markEsphomeSatellite(device, room, address))
665
+ .catch(e => this.log.debug(`satellite state update failed: ${e.message}`));
648
666
  },
667
+ encodeAudio: (pcm, sampleRate, target) => this.runFfmpegEncode(pcm, sampleRate, target),
649
668
  onWakeWords: (device, room, config) => {
650
669
  this.updateSatelliteWakeWords(device, room, config).catch(e => this.log.debug(`satellite wake word update failed: ${e.message}`));
651
670
  },
@@ -689,6 +708,8 @@ class Assistant extends adapter_core_1.Adapter {
689
708
  log: this.log,
690
709
  voskModel: (cfg.voskModel || '').trim(),
691
710
  sttModel: (cfg.sttModel || '').trim(),
711
+ sttBaseUrl: (cfg.sttBaseUrl || '').trim(),
712
+ sttServerKey: cfg.sttServerKey || '',
692
713
  ttsModel: (cfg.ttsModel || '').trim(),
693
714
  openrouterSttModel: (cfg.openrouterSttModel || '').trim(),
694
715
  openrouterTtsModel: (cfg.openrouterTtsModel || '').trim(),
@@ -1100,6 +1121,93 @@ class Assistant extends adapter_core_1.Adapter {
1100
1121
  this.satDeviceById.set(id, device);
1101
1122
  return id;
1102
1123
  }
1124
+ /**
1125
+ * Note which table row an ESPHome satellite belongs to (`native.esphome` on `satellites.<id>`) and show its
1126
+ * address in the host column. That is what lets {@link removeStaleEsphomeSatellites} remove it once the row is
1127
+ * deleted — the id alone is the room or the device name and says nothing about the configuration.
1128
+ */
1129
+ async markEsphomeSatellite(device, room, address) {
1130
+ const id = await this.ensureSatelliteObjects(device, room);
1131
+ if (this.satEsphomeMarked.get(id) === address) {
1132
+ return;
1133
+ }
1134
+ this.satEsphomeMarked.set(id, address);
1135
+ const obj = await this.getObjectAsync(`satellites.${id}`);
1136
+ if (obj?.native?.esphome !== address) {
1137
+ await this.extendObjectAsync(`satellites.${id}`, { native: { esphome: address } });
1138
+ }
1139
+ await this.setStateAsync(`satellites.${id}.host`, { val: address, ack: true });
1140
+ }
1141
+ /**
1142
+ * Remove the objects of ESPHome satellites whose row was deleted from the table (or whose room changed, which
1143
+ * changes the id). Runs before the devices connect, so nothing is re-created meanwhile.
1144
+ *
1145
+ * A satellite marked by {@link markEsphomeSatellite} is matched by its address. One from an older version
1146
+ * carries no mark; it is recognised by `wakeWords` (only ESPHome satellites have it) and removed only when
1147
+ * every row has a room — otherwise a row without one could be it, under its device name.
1148
+ */
1149
+ async removeStaleEsphomeSatellites(devices) {
1150
+ const expected = new Map(devices.map(d => [(0, esphome_1.esphomeAddress)(d), d.room?.trim() ? this.satelliteStateId('', d.room) : null]));
1151
+ const allHaveRooms = [...expected.values()].every(id => id !== null);
1152
+ const roomIds = new Set([...expected.values()].filter((id) => !!id));
1153
+ const prefix = `${this.namespace}.satellites.`;
1154
+ const view = await this.getObjectViewAsync('system', 'device', {
1155
+ startkey: prefix,
1156
+ endkey: `${prefix}香`,
1157
+ });
1158
+ for (const row of view.rows) {
1159
+ const id = row.id.slice(prefix.length);
1160
+ if (!id || id.includes('.')) {
1161
+ continue;
1162
+ }
1163
+ const address = row.value?.native?.esphome;
1164
+ let reason = '';
1165
+ if (address) {
1166
+ if (!expected.has(address)) {
1167
+ reason = `${address} is no longer in the ESPHome satellites table`;
1168
+ }
1169
+ else {
1170
+ const want = expected.get(address);
1171
+ if (want && want !== id) {
1172
+ reason = `${address} is now in room ${want}`;
1173
+ }
1174
+ }
1175
+ }
1176
+ else if (allHaveRooms && !roomIds.has(id) && (await this.getObjectAsync(`satellites.${id}.wakeWords`))) {
1177
+ reason = 'no row of the ESPHome satellites table has this room any more';
1178
+ }
1179
+ if (reason) {
1180
+ this.log.info(`Satellite ${id} removed: ${reason}`);
1181
+ await this.forgetSatellite(id);
1182
+ }
1183
+ }
1184
+ }
1185
+ /** Delete a satellite's objects (Satellites tab). Refused while it is online: it would be re-created at once. */
1186
+ async deleteSatellite(id) {
1187
+ if (!id || /[^\w-]/.test(id)) {
1188
+ throw new Error(`invalid satellite id "${id}"`);
1189
+ }
1190
+ if (!(await this.getObjectAsync(`satellites.${id}`))) {
1191
+ throw new Error(`no satellite ${id}`);
1192
+ }
1193
+ const alive = await this.getStateAsync(`satellites.${id}.alive`);
1194
+ if (alive?.val === true) {
1195
+ throw new Error(`satellite ${id} is online`);
1196
+ }
1197
+ await this.forgetSatellite(id);
1198
+ this.log.info(`Satellite ${id} deleted`);
1199
+ }
1200
+ /** Drop the object tree of one satellite and everything cached about it. */
1201
+ async forgetSatellite(id) {
1202
+ await this.delObjectAsync(`satellites.${id}`, { recursive: true });
1203
+ this.satStatesEnsured.delete(id);
1204
+ this.satWakeWordStatesEnsured.delete(id);
1205
+ this.satVolumeEnsured.delete(id);
1206
+ this.satControlsReady.delete(id);
1207
+ this.satEsphomeMarked.delete(id);
1208
+ this.satDeviceById.delete(id);
1209
+ this.dndById.delete(id);
1210
+ }
1103
1211
  /**
1104
1212
  * Publish a satellite's wake-word configuration and let it be changed from ioBroker.
1105
1213
  *
@@ -1303,6 +1411,10 @@ class Assistant extends adapter_core_1.Adapter {
1303
1411
  native: {},
1304
1412
  });
1305
1413
  for (const entity of entities) {
1414
+ if (!isControlId(entity.objectId)) {
1415
+ this.log.debug(`Satellite ${device}: no usable id for "${entity.name}" (${entity.kind}) — skipped`);
1416
+ continue;
1417
+ }
1306
1418
  const id = `${base}.${entity.objectId}`;
1307
1419
  const name = entity.name || entity.objectId;
1308
1420
  if (entity.kind === 'mediaPlayer') {
@@ -1439,6 +1551,9 @@ class Assistant extends adapter_core_1.Adapter {
1439
1551
  }
1440
1552
  /** Write one entity's current value into its state(s). Always acked — this is the device talking. */
1441
1553
  async writeSatelliteControl(satId, entity) {
1554
+ if (!isControlId(entity.objectId)) {
1555
+ return; // never created (see buildSatelliteControls)
1556
+ }
1442
1557
  const base = `satellites.${satId}.controls.${entity.objectId}`;
1443
1558
  if (entity.kind === 'mediaPlayer') {
1444
1559
  const v = entity.value;
@@ -1904,7 +2019,35 @@ class Assistant extends adapter_core_1.Adapter {
1904
2019
  }
1905
2020
  });
1906
2021
  }
1907
- /** Invalidate the cached device/room/function listings when a room/function enum changes. */
2022
+ /**
2023
+ * Encode mono 16-bit PCM as MP3 or Opus (Ogg) via ffmpeg — for an ESPHome media player that was built with
2024
+ * nothing but that decoder. FLAC and WAV need no tool and are written in `replyFormat.ts`.
2025
+ */
2026
+ runFfmpegEncode(pcm, sampleRate, target) {
2027
+ return new Promise((resolve, reject) => {
2028
+ // prettier-ignore
2029
+ const input = ['-hide_banner', '-loglevel', 'error', '-f', 's16le', '-ar', String(sampleRate), '-ac', '1', '-i', 'pipe:0'];
2030
+ // prettier-ignore
2031
+ const output = target.codec === 'mp3'
2032
+ ? ['-c:a', 'libmp3lame', '-b:a', '128k', '-f', 'mp3']
2033
+ : ['-c:a', 'libopus', '-b:a', '64k', '-f', 'ogg'];
2034
+ // Opus runs at 48/24/16/12/8 kHz only; anything else would make libopus refuse.
2035
+ const rate = target.codec === 'opus' && ![48000, 24000, 16000, 12000, 8000].includes(target.sampleRate)
2036
+ ? 48000
2037
+ : target.sampleRate;
2038
+ const args = [...input, '-ar', String(rate), '-ac', String(target.channels), ...output, '-'];
2039
+ const proc = (0, node_child_process_1.spawn)('ffmpeg', args);
2040
+ const chunks = [];
2041
+ proc.stdout.on('data', (d) => chunks.push(d));
2042
+ proc.stderr.on('data', (d) => this.log.debug(`ffmpeg: ${String(d).trim()}`));
2043
+ proc.on('error', e => reject(new Error(`ffmpeg failed: ${e.message}`)));
2044
+ proc.on('close', code => code === 0 && chunks.length
2045
+ ? resolve(Buffer.concat(chunks))
2046
+ : reject(new Error(`ffmpeg exited with code ${code}`)));
2047
+ proc.stdin.on('error', () => { }); // ignore EPIPE if ffmpeg exits early
2048
+ proc.stdin.end(pcm);
2049
+ });
2050
+ }
1908
2051
  /** Vendor and key of the cloud LLM: speech of the same vendor (OpenAI, OpenRouter) reuses the key. */
1909
2052
  async speechLlmKey() {
1910
2053
  const provider = this.config.provider || 'openai';
@@ -2509,6 +2652,23 @@ class Assistant extends adapter_core_1.Adapter {
2509
2652
  }
2510
2653
  return;
2511
2654
  }
2655
+ // Satellites tab: forget a satellite that is gone. message = { id } (the state id under `satellites.`).
2656
+ if (obj.command === 'deleteSatellite') {
2657
+ const raw = obj.message?.id;
2658
+ const id = typeof raw === 'string' ? raw : '';
2659
+ let payload;
2660
+ try {
2661
+ await this.deleteSatellite(id);
2662
+ payload = { ok: true };
2663
+ }
2664
+ catch (e) {
2665
+ payload = { error: e.message };
2666
+ }
2667
+ if (obj.callback) {
2668
+ this.sendTo(obj.from, obj.command, payload, obj.callback);
2669
+ }
2670
+ return;
2671
+ }
2512
2672
  // Scripts: silence a ringing timer/alarm.
2513
2673
  if (obj.command === 'stopRinging') {
2514
2674
  const n = this.stopRinging();
@@ -2791,6 +2951,7 @@ class Assistant extends adapter_core_1.Adapter {
2791
2951
  fallback: cfg.sttFallback || undefined,
2792
2952
  model: (provider === 'openrouter' ? cfg.openrouterSttModel : cfg.sttModel || cfg.voskModel || '').trim() ||
2793
2953
  undefined,
2954
+ server: (provider === 'openai' && cfg.sttBaseUrl?.trim()) || undefined,
2794
2955
  language: cfg.voiceLanguage || this.language || '',
2795
2956
  audioMs: origin.audioMs,
2796
2957
  wakeWord: origin.wakeWord,