iobroker.assistant 0.1.6 → 1.0.2
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 +61 -4
- package/admin/custom/.vite/manifest.json +122 -77
- package/admin/custom/assets/Components-7BDDNrGP.js +1 -0
- package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-DcAGnE2z.js +1 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-CzBMxGFr.js +1 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-1XsLCK2M.js +1 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-CMv8bcyA.js +1217 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__loadShare__shared-3mzaJWDF.js +2 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-DS8oaQQt.js +122 -0
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__198531368674860__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-B_v57Qfr.js +1 -0
- package/admin/custom/assets/dist-DiU1y0R9.js +1 -0
- package/admin/custom/assets/helpers-Dot7vgQQ.js +1 -0
- package/admin/custom/assets/hostInit-MshDicd1.js +1 -0
- package/admin/custom/assets/iconLibraryData-B5fiyl1C.js +1 -0
- package/admin/custom/assets/index-P2Xu9kJm.js +1 -0
- package/admin/custom/assets/lib-DjwHKtRi.js +3 -0
- package/admin/custom/assets/{mf-entry-bootstrap-0-87f15505.js → mf-entry-bootstrap-0-7c1ddf47.js} +14 -2
- package/admin/custom/assets/pendingShares-CaJDCKLB.js +2 -0
- package/admin/custom/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/admin/custom/assets/utils-towTPKqG.js +4 -0
- package/admin/custom/assets/vite-preload-helper-BbbETgeG.js +1 -0
- package/admin/custom/customComponents.js +2 -2
- package/admin/custom/i18n/de.json +64 -57
- package/admin/custom/i18n/en.json +8 -1
- package/admin/custom/i18n/es.json +64 -57
- package/admin/custom/i18n/fr.json +64 -57
- package/admin/custom/i18n/it.json +64 -57
- package/admin/custom/i18n/nl.json +64 -57
- package/admin/custom/i18n/pl.json +64 -57
- package/admin/custom/i18n/pt.json +64 -57
- package/admin/custom/i18n/ru.json +64 -57
- package/admin/custom/i18n/uk.json +64 -57
- package/admin/custom/i18n/zh-cn.json +64 -57
- package/admin/custom/index.html +2 -2
- package/admin/custom/mf-manifest.json +1 -1
- package/admin/custom/mf-stats.json +1 -1
- package/admin/i18n/de.json +59 -3
- package/admin/i18n/en.json +59 -3
- package/admin/i18n/es.json +59 -3
- package/admin/i18n/fr.json +59 -3
- package/admin/i18n/it.json +59 -3
- package/admin/i18n/nl.json +59 -3
- package/admin/i18n/pl.json +59 -3
- package/admin/i18n/pt.json +59 -3
- package/admin/i18n/ru.json +59 -3
- package/admin/i18n/uk.json +59 -3
- package/admin/i18n/zh-cn.json +59 -3
- package/admin/jsonConfig.json +589 -75
- package/build/lib/ask.js +130 -0
- package/build/lib/ask.js.map +1 -0
- package/build/lib/llm.js +135 -13
- package/build/lib/llm.js.map +1 -1
- package/build/lib/nlu.js +74 -1
- package/build/lib/nlu.js.map +1 -1
- package/build/lib/notifications.js +112 -0
- package/build/lib/notifications.js.map +1 -0
- package/build/lib/presence.js +188 -0
- package/build/lib/presence.js.map +1 -0
- package/build/lib/routines.js +115 -0
- package/build/lib/routines.js.map +1 -0
- package/build/lib/targets.js +96 -0
- package/build/lib/targets.js.map +1 -0
- package/build/lib/triggers.js +632 -0
- package/build/lib/triggers.js.map +1 -0
- package/build/lib/voice/aws.js +9 -1
- package/build/lib/voice/aws.js.map +1 -1
- package/build/lib/voice/azure.js +9 -2
- package/build/lib/voice/azure.js.map +1 -1
- package/build/lib/voice/engines.js +82 -0
- package/build/lib/voice/engines.js.map +1 -1
- package/build/lib/voice/esphome.js +732 -0
- package/build/lib/voice/esphome.js.map +1 -0
- package/build/lib/voice/esphomeEntities.js +303 -0
- package/build/lib/voice/esphomeEntities.js.map +1 -0
- package/build/lib/voice/esphomeProto.js +181 -0
- package/build/lib/voice/esphomeProto.js.map +1 -0
- package/build/lib/voice/fallback.js +85 -0
- package/build/lib/voice/fallback.js.map +1 -0
- package/build/lib/voice/mediaServer.js +151 -0
- package/build/lib/voice/mediaServer.js.map +1 -0
- package/build/lib/voice/protocol.js.map +1 -1
- package/build/lib/voice/tone.js +32 -0
- package/build/lib/voice/tone.js.map +1 -0
- package/build/lib/voice/tts.js.map +1 -1
- package/build/lib/voice/ttsCache.js +274 -0
- package/build/lib/voice/ttsCache.js.map +1 -0
- package/build/lib/voice/vad.js +173 -0
- package/build/lib/voice/vad.js.map +1 -0
- package/build/lib/voice/voiceServer.js +13 -0
- package/build/lib/voice/voiceServer.js.map +1 -1
- package/build/main.js +1721 -67
- package/build/main.js.map +1 -1
- package/io-package.json +241 -3
- package/package.json +13 -12
- package/admin/custom/assets/Components-xWdtqvTH.js +0 -1
- package/admin/custom/assets/_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-dybYZgsu.js +0 -1
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BnSUsPHa.js +0 -1
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-DlSVT49A.js +0 -1
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-Cfsgderv.js +0 -1243
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__1__loadShare___mf_0_iobroker_mf_1_json_mf_2_config__loadShare__.js-ByJf4zv_.js +0 -1494
- package/admin/custom/assets/hostInit-CZawMH9B.js +0 -1
- package/admin/custom/assets/index-JB7g1jgx.js +0 -1
- package/admin/custom/assets/lib-bB-LHRhZ.js +0 -3
- package/admin/custom/assets/rolldown-runtime-C0FnF6B9.js +0 -1
- package/admin/custom/assets/virtualExposes-brKKZvi0.js +0 -1
- package/admin/custom/assets/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__ConfigCustomAssistant__customComponents_js-CRhwj12f.js +0 -1
- package/admin/custom/assets/virtual_mf-exposes___mfe_internal__ConfigCustomAssistant__customComponents_js-C5XHUc1J.js +0 -2
- package/admin/custom/assets/vite-preload-helper-B7qeedMF.js +0 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.registrySize = exports.pb = void 0;
|
|
37
|
+
exports.encode = encode;
|
|
38
|
+
exports.decode = decode;
|
|
39
|
+
/**
|
|
40
|
+
* ESPHome native-API wire codec (plain-text framing) — the transport half of `esphome.ts`.
|
|
41
|
+
*
|
|
42
|
+
* Frame layout: `0x00 | varint(payload length) | varint(message id) | payload (protobuf)`.
|
|
43
|
+
*
|
|
44
|
+
* Only the generated protobuf classes of `@2colors/esphome-native-api` are reused, not its
|
|
45
|
+
* `Connection`/`FrameHelper`: that package's id↔type table stops at the Bluetooth range and omits the
|
|
46
|
+
* whole VoiceAssistant block (89–92, 106, 115, 119–123). Worse, an unknown id makes its frame helper
|
|
47
|
+
* throw *without advancing the read buffer*, so the connection stalls instead of skipping the message.
|
|
48
|
+
* The registry here is derived from the `api.proto` the package ships, which covers every message the
|
|
49
|
+
* device can send, and unknown ids are skipped by their declared length.
|
|
50
|
+
*
|
|
51
|
+
* The device speaks plain text (no noise encryption) — confirmed in the firmware's
|
|
52
|
+
* `linux-voice-assistant-cpp` README ("ESPHome native API server (TCP 6053, plain-text framing,
|
|
53
|
+
* protobuf)") — so the encrypted variant is deliberately not implemented; `decode` reports it clearly
|
|
54
|
+
* if a device ever shows up with encryption switched on.
|
|
55
|
+
*/
|
|
56
|
+
const fs = __importStar(require("node:fs"));
|
|
57
|
+
const path = __importStar(require("node:path"));
|
|
58
|
+
const API_PB_MODULE = '@2colors/esphome-native-api/lib/protoc/api_pb';
|
|
59
|
+
exports.pb = require(API_PB_MODULE);
|
|
60
|
+
const idToName = new Map();
|
|
61
|
+
const classById = new Map();
|
|
62
|
+
/** Message class → its wire id, so `encode` can find the id from an instance. */
|
|
63
|
+
const idByCtor = new Map();
|
|
64
|
+
function buildRegistry() {
|
|
65
|
+
const protoFile = path.join(path.dirname(require.resolve(API_PB_MODULE)), 'api.proto');
|
|
66
|
+
const source = fs.readFileSync(protoFile, 'utf8');
|
|
67
|
+
let current = null;
|
|
68
|
+
for (const raw of source.split(/\r?\n/)) {
|
|
69
|
+
const line = raw.trim();
|
|
70
|
+
const message = /^message\s+(\w+)\s*\{/.exec(line);
|
|
71
|
+
if (message) {
|
|
72
|
+
current = message[1];
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (line === '}') {
|
|
76
|
+
current = null;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const option = /^option\s*\(\s*id\s*\)\s*=\s*(\d+)\s*;/.exec(line);
|
|
80
|
+
if (!option || !current) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const ctor = exports.pb[current];
|
|
84
|
+
if (!ctor) {
|
|
85
|
+
continue; // in the .proto but not in the generated JS — nothing we can do with it
|
|
86
|
+
}
|
|
87
|
+
const id = Number(option[1]);
|
|
88
|
+
idToName.set(id, current);
|
|
89
|
+
classById.set(id, ctor);
|
|
90
|
+
idByCtor.set(ctor, id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
buildRegistry();
|
|
94
|
+
/** Number of messages the registry knows (130 with the shipped api.proto) — used by the unit test. */
|
|
95
|
+
exports.registrySize = idToName.size;
|
|
96
|
+
// ── varint helpers ──────────────────────────────────────────────────────────────────────────────────
|
|
97
|
+
function encodeVarint(value) {
|
|
98
|
+
const out = [];
|
|
99
|
+
let rest = value >>> 0;
|
|
100
|
+
do {
|
|
101
|
+
let byte = rest & 0x7f;
|
|
102
|
+
rest >>>= 7;
|
|
103
|
+
if (rest) {
|
|
104
|
+
byte |= 0x80;
|
|
105
|
+
}
|
|
106
|
+
out.push(byte);
|
|
107
|
+
} while (rest);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** Read a varint at `offset`; null when the buffer holds only part of it (wait for more data). */
|
|
111
|
+
function readVarint(buf, offset) {
|
|
112
|
+
let result = 0;
|
|
113
|
+
let shift = 0;
|
|
114
|
+
let pos = offset;
|
|
115
|
+
while (pos < buf.length) {
|
|
116
|
+
const byte = buf[pos++];
|
|
117
|
+
result |= (byte & 0x7f) << shift;
|
|
118
|
+
if (!(byte & 0x80)) {
|
|
119
|
+
return { value: result >>> 0, offset: pos };
|
|
120
|
+
}
|
|
121
|
+
shift += 7;
|
|
122
|
+
if (shift > 28) {
|
|
123
|
+
throw new Error('malformed varint');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
// ── framing ─────────────────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
/** Serialise one protobuf message into a complete frame. */
|
|
130
|
+
function encode(message) {
|
|
131
|
+
const id = idByCtor.get(message.constructor);
|
|
132
|
+
if (id === undefined) {
|
|
133
|
+
throw new Error('message class is not registered in the ESPHome id table');
|
|
134
|
+
}
|
|
135
|
+
const payload = Buffer.from(message.serializeBinary());
|
|
136
|
+
return Buffer.concat([Buffer.from([0, ...encodeVarint(payload.length), ...encodeVarint(id)]), payload]);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Pull every complete frame out of `buffer`. Returns the frames plus the unconsumed tail; the input is
|
|
140
|
+
* never mutated, so the caller just keeps assigning `rest` back to its receive buffer.
|
|
141
|
+
*/
|
|
142
|
+
function decode(buffer) {
|
|
143
|
+
const frames = [];
|
|
144
|
+
let offset = 0;
|
|
145
|
+
while (offset < buffer.length) {
|
|
146
|
+
if (buffer[offset] !== 0) {
|
|
147
|
+
throw new Error(buffer[offset] === 1
|
|
148
|
+
? 'device uses the encrypted (noise) ESPHome API — only plain text is supported'
|
|
149
|
+
: `bad frame preamble 0x${buffer[offset].toString(16)}`);
|
|
150
|
+
}
|
|
151
|
+
const length = readVarint(buffer, offset + 1);
|
|
152
|
+
if (!length) {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
const id = readVarint(buffer, length.offset);
|
|
156
|
+
if (!id) {
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
const end = id.offset + length.value;
|
|
160
|
+
if (end > buffer.length) {
|
|
161
|
+
break; // frame not fully received yet
|
|
162
|
+
}
|
|
163
|
+
const payload = buffer.subarray(id.offset, end);
|
|
164
|
+
const name = idToName.get(id.value) ?? null;
|
|
165
|
+
const ctor = classById.get(id.value);
|
|
166
|
+
let message = null;
|
|
167
|
+
let error = null;
|
|
168
|
+
if (ctor) {
|
|
169
|
+
try {
|
|
170
|
+
message = ctor.deserializeBinary(payload);
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
error = e;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
frames.push({ id: id.value, name, message, error });
|
|
177
|
+
offset = end;
|
|
178
|
+
}
|
|
179
|
+
return { frames, rest: buffer.subarray(offset) };
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=esphomeProto.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"esphomeProto.js","sourceRoot":"","sources":["../../../src/lib/voice/esphomeProto.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ESPHome native-API wire codec (plain-text framing) — the transport half of `esphome.ts`.\n *\n * Frame layout: `0x00 | varint(payload length) | varint(message id) | payload (protobuf)`.\n *\n * Only the generated protobuf classes of `@2colors/esphome-native-api` are reused, not its\n * `Connection`/`FrameHelper`: that package's id↔type table stops at the Bluetooth range and omits the\n * whole VoiceAssistant block (89–92, 106, 115, 119–123). Worse, an unknown id makes its frame helper\n * throw *without advancing the read buffer*, so the connection stalls instead of skipping the message.\n * The registry here is derived from the `api.proto` the package ships, which covers every message the\n * device can send, and unknown ids are skipped by their declared length.\n *\n * The device speaks plain text (no noise encryption) — confirmed in the firmware's\n * `linux-voice-assistant-cpp` README (\"ESPHome native API server (TCP 6053, plain-text framing,\n * protobuf)\") — so the encrypted variant is deliberately not implemented; `decode` reports it clearly\n * if a device ever shows up with encryption switched on.\n */\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\n// ── minimal structural typing over the generated protobuf classes ───────────────────────────────────\n\nexport interface PbMessage {\n serializeBinary(): Uint8Array;\n}\n\n/** Messages we build and send (client → device). */\nexport interface HelloRequest extends PbMessage {\n setClientInfo(value: string): void;\n setApiVersionMajor(value: number): void;\n setApiVersionMinor(value: number): void;\n}\nexport interface AuthenticationRequest extends PbMessage {\n setPassword(value: string): void;\n}\nexport interface SubscribeVoiceAssistantRequest extends PbMessage {\n setSubscribe(value: boolean): void;\n setFlags(value: number): void;\n}\nexport interface VoiceAssistantResponseMsg extends PbMessage {\n setPort(value: number): void;\n setError(value: boolean): void;\n}\nexport interface VoiceAssistantEventData extends PbMessage {\n setName(value: string): void;\n setValue(value: string): void;\n}\nexport interface VoiceAssistantEventResponse extends PbMessage {\n setEventType(value: number): void;\n setDataList(value: VoiceAssistantEventData[]): void;\n}\nexport interface VoiceAssistantAnnounceRequest extends PbMessage {\n setMediaId(value: string): void;\n setText(value: string): void;\n setPreannounceMediaId(value: string): void;\n setStartConversation(value: boolean): void;\n}\nexport interface GetTimeResponse extends PbMessage {\n setEpochSeconds(value: number): void;\n}\n/** Selects which of the device's wake words are listening; the device persists this itself. */\nexport interface VoiceAssistantSetConfiguration extends PbMessage {\n setActiveWakeWordsList(value: string[]): void;\n}\n\n// Entity command messages. All address an entity by the `fixed32 key` from its announcement.\nexport interface SwitchCommandRequest extends PbMessage {\n setKey(value: number): void;\n setState(value: boolean): void;\n}\nexport interface NumberCommandRequest extends PbMessage {\n setKey(value: number): void;\n setState(value: number): void;\n}\nexport interface SelectCommandRequest extends PbMessage {\n setKey(value: number): void;\n setState(value: string): void;\n}\nexport interface UpdateCommandRequest extends PbMessage {\n setKey(value: number): void;\n setCommand(value: number): void;\n}\nexport interface MediaPlayerCommandRequest extends PbMessage {\n setKey(value: number): void;\n setHasCommand(value: boolean): void;\n setCommand(value: number): void;\n setHasVolume(value: boolean): void;\n setVolume(value: number): void;\n}\n/** Mirrors an assistant timer onto the device, which drives its own LED ring and gong. */\nexport interface VoiceAssistantTimerEventResponse extends PbMessage {\n setEventType(value: number): void;\n setTimerId(value: string): void;\n setName(value: string): void;\n setTotalSeconds(value: number): void;\n setSecondsLeft(value: number): void;\n setIsActive(value: boolean): void;\n}\n\n/** Messages we receive (device → client). */\nexport interface HelloResponse {\n getApiVersionMajor(): number;\n getApiVersionMinor(): number;\n getServerInfo(): string;\n getName(): string;\n}\nexport interface AuthenticationResponse {\n getInvalidPassword(): boolean;\n}\nexport interface DeviceInfoResponse {\n getName(): string;\n getModel(): string;\n getEsphomeVersion(): string;\n getMacAddress(): string;\n}\nexport interface VoiceAssistantWakeWord {\n getId(): string;\n getWakeWord(): string;\n /** ISO-639-1 codes the model was trained on — only `okay_nabu` covers more than English. */\n getTrainedLanguagesList(): string[];\n}\nexport interface VoiceAssistantConfigurationResponse {\n getAvailableWakeWordsList(): VoiceAssistantWakeWord[];\n getActiveWakeWordsList(): string[];\n /** How many wake words may listen at once (2 on the ThirdReality speaker). */\n getMaxActiveWakeWords(): number;\n}\nexport interface VoiceAssistantRequestMsg {\n getStart(): boolean;\n getConversationId(): string;\n getWakeWordPhrase(): string;\n}\nexport interface VoiceAssistantAudio {\n getData_asU8(): Uint8Array;\n getEnd(): boolean;\n}\nexport interface VoiceAssistantAnnounceFinished {\n getSuccess(): boolean;\n}\n\ntype Ctor<T> = new () => T;\n\ninterface ApiPb {\n HelloRequest: Ctor<HelloRequest>;\n AuthenticationRequest: Ctor<AuthenticationRequest>;\n DeviceInfoRequest: Ctor<PbMessage>;\n ListEntitiesRequest: Ctor<PbMessage>;\n SubscribeStatesRequest: Ctor<PbMessage>;\n SubscribeVoiceAssistantRequest: Ctor<SubscribeVoiceAssistantRequest>;\n VoiceAssistantConfigurationRequest: Ctor<PbMessage>;\n VoiceAssistantSetConfiguration: Ctor<VoiceAssistantSetConfiguration>;\n VoiceAssistantTimerEventResponse: Ctor<VoiceAssistantTimerEventResponse>;\n SwitchCommandRequest: Ctor<SwitchCommandRequest>;\n NumberCommandRequest: Ctor<NumberCommandRequest>;\n SelectCommandRequest: Ctor<SelectCommandRequest>;\n UpdateCommandRequest: Ctor<UpdateCommandRequest>;\n MediaPlayerCommandRequest: Ctor<MediaPlayerCommandRequest>;\n VoiceAssistantResponse: Ctor<VoiceAssistantResponseMsg>;\n VoiceAssistantEventResponse: Ctor<VoiceAssistantEventResponse>;\n VoiceAssistantEventData: Ctor<VoiceAssistantEventData>;\n VoiceAssistantAnnounceRequest: Ctor<VoiceAssistantAnnounceRequest>;\n PingRequest: Ctor<PbMessage>;\n PingResponse: Ctor<PbMessage>;\n GetTimeResponse: Ctor<GetTimeResponse>;\n DisconnectRequest: Ctor<PbMessage>;\n DisconnectResponse: Ctor<PbMessage>;\n [name: string]: Ctor<unknown> | undefined;\n}\n\nconst API_PB_MODULE = '@2colors/esphome-native-api/lib/protoc/api_pb';\n\nexport const pb = require(API_PB_MODULE) as ApiPb;\n\n// ── message registry, built from the shipped api.proto ──────────────────────────────────────────────\n\n/** Deserialiser for one message id. */\ninterface Deserialiser {\n deserializeBinary(bytes: Uint8Array): unknown;\n}\n\nconst idToName = new Map<number, string>();\nconst classById = new Map<number, Deserialiser>();\n/** Message class → its wire id, so `encode` can find the id from an instance. */\nconst idByCtor = new Map<unknown, number>();\n\nfunction buildRegistry(): void {\n const protoFile = path.join(path.dirname(require.resolve(API_PB_MODULE)), 'api.proto');\n const source = fs.readFileSync(protoFile, 'utf8');\n let current: string | null = null;\n\n for (const raw of source.split(/\\r?\\n/)) {\n const line = raw.trim();\n const message = /^message\\s+(\\w+)\\s*\\{/.exec(line);\n if (message) {\n current = message[1];\n continue;\n }\n if (line === '}') {\n current = null;\n continue;\n }\n const option = /^option\\s*\\(\\s*id\\s*\\)\\s*=\\s*(\\d+)\\s*;/.exec(line);\n if (!option || !current) {\n continue;\n }\n const ctor = pb[current];\n if (!ctor) {\n continue; // in the .proto but not in the generated JS — nothing we can do with it\n }\n const id = Number(option[1]);\n idToName.set(id, current);\n classById.set(id, ctor as unknown as Deserialiser);\n idByCtor.set(ctor, id);\n }\n}\nbuildRegistry();\n\n/** Number of messages the registry knows (130 with the shipped api.proto) — used by the unit test. */\nexport const registrySize = idToName.size;\n\n// ── varint helpers ──────────────────────────────────────────────────────────────────────────────────\n\nfunction encodeVarint(value: number): number[] {\n const out: number[] = [];\n let rest = value >>> 0;\n do {\n let byte = rest & 0x7f;\n rest >>>= 7;\n if (rest) {\n byte |= 0x80;\n }\n out.push(byte);\n } while (rest);\n return out;\n}\n\n/** Read a varint at `offset`; null when the buffer holds only part of it (wait for more data). */\nfunction readVarint(buf: Buffer, offset: number): { value: number; offset: number } | null {\n let result = 0;\n let shift = 0;\n let pos = offset;\n while (pos < buf.length) {\n const byte = buf[pos++];\n result |= (byte & 0x7f) << shift;\n if (!(byte & 0x80)) {\n return { value: result >>> 0, offset: pos };\n }\n shift += 7;\n if (shift > 28) {\n throw new Error('malformed varint');\n }\n }\n return null;\n}\n\n// ── framing ─────────────────────────────────────────────────────────────────────────────────────────\n\n/** Serialise one protobuf message into a complete frame. */\nexport function encode(message: PbMessage): Buffer {\n const id = idByCtor.get((message as object).constructor);\n if (id === undefined) {\n throw new Error('message class is not registered in the ESPHome id table');\n }\n const payload = Buffer.from(message.serializeBinary());\n return Buffer.concat([Buffer.from([0, ...encodeVarint(payload.length), ...encodeVarint(id)]), payload]);\n}\n\nexport interface DecodedFrame {\n id: number;\n /** null for a message id the registry doesn't know — the frame was skipped by its declared length. */\n name: string | null;\n /** The decoded message, or null when the id is unknown or the payload didn't parse. */\n message: unknown;\n /** Set when a known message failed to deserialise. */\n error: Error | null;\n}\n\n/**\n * Pull every complete frame out of `buffer`. Returns the frames plus the unconsumed tail; the input is\n * never mutated, so the caller just keeps assigning `rest` back to its receive buffer.\n */\nexport function decode(buffer: Buffer): { frames: DecodedFrame[]; rest: Buffer } {\n const frames: DecodedFrame[] = [];\n let offset = 0;\n\n while (offset < buffer.length) {\n if (buffer[offset] !== 0) {\n throw new Error(\n buffer[offset] === 1\n ? 'device uses the encrypted (noise) ESPHome API — only plain text is supported'\n : `bad frame preamble 0x${buffer[offset].toString(16)}`,\n );\n }\n const length = readVarint(buffer, offset + 1);\n if (!length) {\n break;\n }\n const id = readVarint(buffer, length.offset);\n if (!id) {\n break;\n }\n const end = id.offset + length.value;\n if (end > buffer.length) {\n break; // frame not fully received yet\n }\n\n const payload = buffer.subarray(id.offset, end);\n const name = idToName.get(id.value) ?? null;\n const ctor = classById.get(id.value);\n let message: unknown = null;\n let error: Error | null = null;\n if (ctor) {\n try {\n message = ctor.deserializeBinary(payload);\n } catch (e) {\n error = e as Error;\n }\n }\n frames.push({ id: id.value, name, message, error });\n offset = end;\n }\n\n return { frames, rest: buffer.subarray(offset) };\n}\n"]}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FallbackTts = exports.FallbackStt = void 0;
|
|
4
|
+
/** Run `attempt` over the engines in order; the last error survives if all of them fail. */
|
|
5
|
+
async function overEngines(engines, what, log, attempt) {
|
|
6
|
+
let last;
|
|
7
|
+
for (const [index, entry] of engines.entries()) {
|
|
8
|
+
try {
|
|
9
|
+
const result = await attempt(entry.engine);
|
|
10
|
+
if (index > 0) {
|
|
11
|
+
log.info(`${what}: answered by the fallback engine (${entry.name}).`);
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
last = e;
|
|
17
|
+
const next = engines[index + 1];
|
|
18
|
+
log[next ? 'warn' : 'error'](`${what}: ${entry.name} failed (${last.message})${next ? ` — trying ${next.name}` : ''}.`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
throw last ?? new Error(`${what}: no engine configured`);
|
|
22
|
+
}
|
|
23
|
+
/** Speech-to-text with one fallback engine. */
|
|
24
|
+
class FallbackStt {
|
|
25
|
+
engines;
|
|
26
|
+
log;
|
|
27
|
+
constructor(engines, log) {
|
|
28
|
+
this.engines = engines;
|
|
29
|
+
this.log = log;
|
|
30
|
+
}
|
|
31
|
+
transcribe(pcm, sampleRate, lang, hints) {
|
|
32
|
+
return overEngines(this.engines, 'Speech-to-text', this.log, e => e.transcribe(pcm, sampleRate, lang, hints));
|
|
33
|
+
}
|
|
34
|
+
/** Prepare every engine: the fallback is useless if its model is only downloaded once it is needed. */
|
|
35
|
+
async prepare(lang) {
|
|
36
|
+
for (const entry of this.engines) {
|
|
37
|
+
try {
|
|
38
|
+
await entry.engine.prepare?.(lang);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
this.log.warn(`Speech-to-text: preparing ${entry.name} failed (${e.message}).`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.FallbackStt = FallbackStt;
|
|
47
|
+
/** Text-to-speech with one fallback engine. */
|
|
48
|
+
class FallbackTts {
|
|
49
|
+
engines;
|
|
50
|
+
log;
|
|
51
|
+
constructor(engines, log) {
|
|
52
|
+
this.engines = engines;
|
|
53
|
+
this.log = log;
|
|
54
|
+
}
|
|
55
|
+
synthesize(text, lang) {
|
|
56
|
+
return overEngines(this.engines, 'Text-to-speech', this.log, e => e.synthesize(text, lang));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* SSML only goes to engines that can speak it; if none can, the primary's plain-text path handles it
|
|
60
|
+
* (its cache wrapper strips the markup).
|
|
61
|
+
*/
|
|
62
|
+
synthesizeSsml(ssml, lang) {
|
|
63
|
+
const capable = this.engines.filter(e => !!e.engine.synthesizeSsml);
|
|
64
|
+
if (!capable.length) {
|
|
65
|
+
return this.synthesize(ssml, lang);
|
|
66
|
+
}
|
|
67
|
+
return overEngines(capable, 'Text-to-speech (SSML)', this.log, e => e.synthesizeSsml(ssml, lang));
|
|
68
|
+
}
|
|
69
|
+
async prepare(lang) {
|
|
70
|
+
for (const entry of this.engines) {
|
|
71
|
+
try {
|
|
72
|
+
await entry.engine.prepare?.(lang);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
this.log.warn(`Text-to-speech: preparing ${entry.name} failed (${e.message}).`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Warm only the primary's cache: the fallback is for an outage, not for everyday phrases. */
|
|
80
|
+
warm(phrases, lang) {
|
|
81
|
+
return this.engines[0]?.engine.warm?.(phrases, lang) ?? Promise.resolve(0);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.FallbackTts = FallbackTts;
|
|
85
|
+
//# sourceMappingURL=fallback.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fallback.js","sourceRoot":"","sources":["../../../src/lib/voice/fallback.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * A second speech engine behind the first one, so an outage does not take the voice away.\n *\n * The usual setup is a cloud engine with a local one behind it: when OpenAI/Azure/AWS is unreachable, the\n * API key expired or the quota ran out, Vosk and Piper keep the house answering — worse sounding, but\n * answering. That is the whole value: without it, the assistant goes mute exactly when the internet does.\n *\n * The idea comes from the Python original (`C:\\iot\\Hannah`, `core/hannah/stt.py`, which chains\n * aws → azure → remote → local); here it is one explicit fallback rather than an open chain, because a\n * household configures at most two speech providers and a longer chain would mostly be a way to wait\n * several timeouts in a row.\n *\n * Any failure of the primary moves on to the fallback: the alternative would be classifying provider\n * errors into \"retryable\" and \"not\", which is guesswork across three SDKs — and a request the primary\n * cannot answer is one the user still wants answered.\n */\nimport type { SttEngine } from './stt';\nimport type { TtsEngine, TtsResult } from './tts';\nimport type { VoiceLogger } from './download';\n\n/** One named engine in the chain. */\nexport interface NamedEngine<T> {\n name: string;\n engine: T;\n}\n\n/** Run `attempt` over the engines in order; the last error survives if all of them fail. */\nasync function overEngines<T, R>(\n engines: NamedEngine<T>[],\n what: string,\n log: VoiceLogger,\n attempt: (engine: T) => Promise<R>,\n): Promise<R> {\n let last: Error | undefined;\n for (const [index, entry] of engines.entries()) {\n try {\n const result = await attempt(entry.engine);\n if (index > 0) {\n log.info(`${what}: answered by the fallback engine (${entry.name}).`);\n }\n return result;\n } catch (e) {\n last = e as Error;\n const next = engines[index + 1];\n log[next ? 'warn' : 'error'](\n `${what}: ${entry.name} failed (${last.message})${next ? ` — trying ${next.name}` : ''}.`,\n );\n }\n }\n throw last ?? new Error(`${what}: no engine configured`);\n}\n\n/** Speech-to-text with one fallback engine. */\nexport class FallbackStt implements SttEngine {\n constructor(\n private readonly engines: NamedEngine<SttEngine>[],\n private readonly log: VoiceLogger,\n ) {}\n\n transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string> {\n return overEngines(this.engines, 'Speech-to-text', this.log, e => e.transcribe(pcm, sampleRate, lang, hints));\n }\n\n /** Prepare every engine: the fallback is useless if its model is only downloaded once it is needed. */\n async prepare(lang: string): Promise<void> {\n for (const entry of this.engines) {\n try {\n await entry.engine.prepare?.(lang);\n } catch (e) {\n this.log.warn(`Speech-to-text: preparing ${entry.name} failed (${(e as Error).message}).`);\n }\n }\n }\n}\n\n/** Text-to-speech with one fallback engine. */\nexport class FallbackTts implements TtsEngine {\n constructor(\n private readonly engines: NamedEngine<TtsEngine>[],\n private readonly log: VoiceLogger,\n ) {}\n\n synthesize(text: string, lang: string): Promise<TtsResult> {\n return overEngines(this.engines, 'Text-to-speech', this.log, e => e.synthesize(text, lang));\n }\n\n /**\n * SSML only goes to engines that can speak it; if none can, the primary's plain-text path handles it\n * (its cache wrapper strips the markup).\n */\n synthesizeSsml(ssml: string, lang: string): Promise<TtsResult> {\n const capable = this.engines.filter(e => !!e.engine.synthesizeSsml);\n if (!capable.length) {\n return this.synthesize(ssml, lang);\n }\n return overEngines(capable, 'Text-to-speech (SSML)', this.log, e => e.synthesizeSsml!(ssml, lang));\n }\n\n async prepare(lang: string): Promise<void> {\n for (const entry of this.engines) {\n try {\n await entry.engine.prepare?.(lang);\n } catch (e) {\n this.log.warn(`Text-to-speech: preparing ${entry.name} failed (${(e as Error).message}).`);\n }\n }\n }\n\n /** Warm only the primary's cache: the fallback is for an outage, not for everyday phrases. */\n warm(phrases: string[], lang: string): Promise<number> {\n return this.engines[0]?.engine.warm?.(phrases, lang) ?? Promise.resolve(0);\n }\n}\n"]}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.MediaServer = void 0;
|
|
37
|
+
/**
|
|
38
|
+
* Tiny HTTP server that hands out short-lived audio clips.
|
|
39
|
+
*
|
|
40
|
+
* ESPHome voice satellites do not receive the spoken reply over their API socket: the server puts a
|
|
41
|
+
* **URL** into the `tts-end` event (and into `VoiceAssistantAnnounceRequest.media_id`), and the device
|
|
42
|
+
* fetches and plays it with mpv. So the adapter has to be an HTTP origin for as long as a clip is
|
|
43
|
+
* needed — that is all this is.
|
|
44
|
+
*
|
|
45
|
+
* Clips live in memory, are addressed by an unguessable token and expire on their own, so nothing is
|
|
46
|
+
* written to disk and a clip cannot be replayed indefinitely by anyone who once saw the URL.
|
|
47
|
+
*/
|
|
48
|
+
const http = __importStar(require("node:http"));
|
|
49
|
+
const node_crypto_1 = require("node:crypto");
|
|
50
|
+
/** How long a published clip stays fetchable. Generous: a device may buffer before it starts playing. */
|
|
51
|
+
const DEFAULT_TTL_MS = 120_000;
|
|
52
|
+
/** Safety net against a leak if a device never fetches what we published. */
|
|
53
|
+
const MAX_CLIPS = 64;
|
|
54
|
+
class MediaServer {
|
|
55
|
+
opts;
|
|
56
|
+
server = null;
|
|
57
|
+
clips = new Map();
|
|
58
|
+
sweepTimer = null;
|
|
59
|
+
/** Port the OS actually handed out — differs from the configured one when that is 0. */
|
|
60
|
+
boundPort = 0;
|
|
61
|
+
constructor(opts) {
|
|
62
|
+
this.opts = opts;
|
|
63
|
+
}
|
|
64
|
+
/** Port the server listens on. Only meaningful after `start()` when port 0 was configured. */
|
|
65
|
+
get port() {
|
|
66
|
+
return this.boundPort || this.opts.port;
|
|
67
|
+
}
|
|
68
|
+
start() {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const server = http.createServer((req, res) => this.onRequest(req, res));
|
|
71
|
+
this.server = server;
|
|
72
|
+
server.once('error', reject);
|
|
73
|
+
server.on('error', e => this.opts.log.error(`media server error: ${e.message}`));
|
|
74
|
+
const address = this.opts.bindAddress && this.opts.bindAddress !== '0.0.0.0' ? this.opts.bindAddress : undefined;
|
|
75
|
+
const onListening = () => {
|
|
76
|
+
const bound = server.address();
|
|
77
|
+
this.boundPort = typeof bound === 'object' && bound ? bound.port : this.opts.port;
|
|
78
|
+
this.opts.log.info(`Media server listening on http://${address || '0.0.0.0'}:${this.port}`);
|
|
79
|
+
this.sweepTimer = setInterval(() => this.sweep(), 30_000);
|
|
80
|
+
this.sweepTimer.unref();
|
|
81
|
+
resolve();
|
|
82
|
+
};
|
|
83
|
+
if (address) {
|
|
84
|
+
server.listen(this.opts.port, address, onListening);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
server.listen(this.opts.port, onListening);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async stop() {
|
|
92
|
+
if (this.sweepTimer) {
|
|
93
|
+
clearInterval(this.sweepTimer);
|
|
94
|
+
this.sweepTimer = null;
|
|
95
|
+
}
|
|
96
|
+
this.clips.clear();
|
|
97
|
+
const server = this.server;
|
|
98
|
+
this.server = null;
|
|
99
|
+
if (server) {
|
|
100
|
+
server.closeAllConnections?.();
|
|
101
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Publish a clip and return the path it is served under (e.g. `/media/ab12….wav`). The caller
|
|
106
|
+
* prefixes the host it wants the device to call back on — which interface that is depends on the
|
|
107
|
+
* device, so only the caller can know it.
|
|
108
|
+
*/
|
|
109
|
+
publish(body, contentType = 'audio/wav', ttlMs = DEFAULT_TTL_MS) {
|
|
110
|
+
if (this.clips.size >= MAX_CLIPS) {
|
|
111
|
+
this.sweep();
|
|
112
|
+
// Still full? Drop the oldest — insertion order is age order.
|
|
113
|
+
const oldest = this.clips.keys().next();
|
|
114
|
+
if (this.clips.size >= MAX_CLIPS && !oldest.done) {
|
|
115
|
+
this.clips.delete(oldest.value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const extension = contentType === 'audio/mpeg' ? 'mp3' : 'wav';
|
|
119
|
+
const token = `${(0, node_crypto_1.randomBytes)(12).toString('hex')}.${extension}`;
|
|
120
|
+
this.clips.set(token, { body, contentType, expiresAt: Date.now() + ttlMs });
|
|
121
|
+
return `/media/${token}`;
|
|
122
|
+
}
|
|
123
|
+
onRequest(req, res) {
|
|
124
|
+
const token = /^\/media\/([\w.]+)$/.exec((req.url || '').split('?')[0])?.[1];
|
|
125
|
+
const clip = token ? this.clips.get(token) : undefined;
|
|
126
|
+
if (!clip || clip.expiresAt < Date.now()) {
|
|
127
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
128
|
+
res.end('not found');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this.opts.log.debug(`media: ${req.method} ${req.url} → ${clip.body.length} bytes`);
|
|
132
|
+
res.writeHead(200, {
|
|
133
|
+
'Content-Type': clip.contentType,
|
|
134
|
+
'Content-Length': clip.body.length,
|
|
135
|
+
'Cache-Control': 'no-store',
|
|
136
|
+
// mpv asks for ranges on some builds; saying so up front avoids a needless second request.
|
|
137
|
+
'Accept-Ranges': 'none',
|
|
138
|
+
});
|
|
139
|
+
res.end(req.method === 'HEAD' ? undefined : clip.body);
|
|
140
|
+
}
|
|
141
|
+
sweep() {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
for (const [token, clip] of this.clips) {
|
|
144
|
+
if (clip.expiresAt < now) {
|
|
145
|
+
this.clips.delete(token);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
exports.MediaServer = MediaServer;
|
|
151
|
+
//# sourceMappingURL=mediaServer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mediaServer.js","sourceRoot":"","sources":["../../../src/lib/voice/mediaServer.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Tiny HTTP server that hands out short-lived audio clips.\n *\n * ESPHome voice satellites do not receive the spoken reply over their API socket: the server puts a\n * **URL** into the `tts-end` event (and into `VoiceAssistantAnnounceRequest.media_id`), and the device\n * fetches and plays it with mpv. So the adapter has to be an HTTP origin for as long as a clip is\n * needed — that is all this is.\n *\n * Clips live in memory, are addressed by an unguessable token and expire on their own, so nothing is\n * written to disk and a clip cannot be replayed indefinitely by anyone who once saw the URL.\n */\nimport * as http from 'node:http';\nimport { randomBytes } from 'node:crypto';\n\ninterface Clip {\n body: Buffer;\n contentType: string;\n expiresAt: number;\n}\n\nexport interface MediaServerOptions {\n port: number;\n /** Interface to bind ('0.0.0.0'/'' = all). */\n bindAddress?: string;\n log: ioBroker.Logger;\n}\n\n/** How long a published clip stays fetchable. Generous: a device may buffer before it starts playing. */\nconst DEFAULT_TTL_MS = 120_000;\n/** Safety net against a leak if a device never fetches what we published. */\nconst MAX_CLIPS = 64;\n\nexport class MediaServer {\n private server: http.Server | null = null;\n private readonly clips = new Map<string, Clip>();\n private sweepTimer: NodeJS.Timeout | null = null;\n /** Port the OS actually handed out — differs from the configured one when that is 0. */\n private boundPort = 0;\n\n constructor(private readonly opts: MediaServerOptions) {}\n\n /** Port the server listens on. Only meaningful after `start()` when port 0 was configured. */\n get port(): number {\n return this.boundPort || this.opts.port;\n }\n\n start(): Promise<void> {\n return new Promise((resolve, reject) => {\n const server = http.createServer((req, res) => this.onRequest(req, res));\n this.server = server;\n server.once('error', reject);\n server.on('error', e => this.opts.log.error(`media server error: ${e.message}`));\n const address =\n this.opts.bindAddress && this.opts.bindAddress !== '0.0.0.0' ? this.opts.bindAddress : undefined;\n const onListening = (): void => {\n const bound = server.address();\n this.boundPort = typeof bound === 'object' && bound ? bound.port : this.opts.port;\n this.opts.log.info(`Media server listening on http://${address || '0.0.0.0'}:${this.port}`);\n this.sweepTimer = setInterval(() => this.sweep(), 30_000);\n this.sweepTimer.unref();\n resolve();\n };\n if (address) {\n server.listen(this.opts.port, address, onListening);\n } else {\n server.listen(this.opts.port, onListening);\n }\n });\n }\n\n async stop(): Promise<void> {\n if (this.sweepTimer) {\n clearInterval(this.sweepTimer);\n this.sweepTimer = null;\n }\n this.clips.clear();\n const server = this.server;\n this.server = null;\n if (server) {\n server.closeAllConnections?.();\n await new Promise<void>(resolve => server.close(() => resolve()));\n }\n }\n\n /**\n * Publish a clip and return the path it is served under (e.g. `/media/ab12….wav`). The caller\n * prefixes the host it wants the device to call back on — which interface that is depends on the\n * device, so only the caller can know it.\n */\n publish(body: Buffer, contentType = 'audio/wav', ttlMs = DEFAULT_TTL_MS): string {\n if (this.clips.size >= MAX_CLIPS) {\n this.sweep();\n // Still full? Drop the oldest — insertion order is age order.\n const oldest = this.clips.keys().next();\n if (this.clips.size >= MAX_CLIPS && !oldest.done) {\n this.clips.delete(oldest.value);\n }\n }\n const extension = contentType === 'audio/mpeg' ? 'mp3' : 'wav';\n const token = `${randomBytes(12).toString('hex')}.${extension}`;\n this.clips.set(token, { body, contentType, expiresAt: Date.now() + ttlMs });\n return `/media/${token}`;\n }\n\n private onRequest(req: http.IncomingMessage, res: http.ServerResponse): void {\n const token = /^\\/media\\/([\\w.]+)$/.exec((req.url || '').split('?')[0])?.[1];\n const clip = token ? this.clips.get(token) : undefined;\n\n if (!clip || clip.expiresAt < Date.now()) {\n res.writeHead(404, { 'Content-Type': 'text/plain' });\n res.end('not found');\n return;\n }\n this.opts.log.debug(`media: ${req.method} ${req.url} → ${clip.body.length} bytes`);\n res.writeHead(200, {\n 'Content-Type': clip.contentType,\n 'Content-Length': clip.body.length,\n 'Cache-Control': 'no-store',\n // mpv asks for ranges on some builds; saying so up front avoids a needless second request.\n 'Accept-Ranges': 'none',\n });\n res.end(req.method === 'HEAD' ? undefined : clip.body);\n }\n\n private sweep(): void {\n const now = Date.now();\n for (const [token, clip] of this.clips) {\n if (clip.expiresAt < now) {\n this.clips.delete(token);\n }\n }\n }\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../../src/lib/voice/protocol.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG;;;
|
|
1
|
+
{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../../src/lib/voice/protocol.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG;;;AAkCH,sCAEC;AAGD,8BAEC;AAGD,oCAEC;AA5CY,QAAA,YAAY,GAAG,IAAI,CAAC;AACpB,QAAA,UAAU,GAAG,IAAI,CAAC;AAClB,QAAA,QAAQ,GAAG,IAAI,CAAC;AAE7B,oGAAoG;AACvF,QAAA,iBAAiB,GAAG,KAAK,CAAC;AA0BvC,gEAAgE;AAChE,SAAgB,aAAa,CAAC,GAAgB;IAC1C,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,oBAAY,CAAC,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;AAClG,CAAC;AAED,qDAAqD;AACrD,SAAgB,SAAS,CAAC,GAAW;IACjC,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,gBAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;AACzD,CAAC;AAED,mGAAmG;AACnG,SAAgB,YAAY,CAAC,IAAY;IACrC,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC;AACxD,CAAC","sourcesContent":["/**\n * Voice-satellite UDP wire protocol — compatible with the Hannah satellite\n * (`C:\\iot\\Hannah\\satellite-pi\\satellite.py`) so existing ESP/Python satellites keep working.\n *\n * One UDP socket, first byte is the packet type:\n * 0x01 CONTROL — UTF-8 JSON message (both directions)\n * 0x02 AUDIO — raw 16 kHz mono 16-bit signed LE PCM (satellite → server)\n * 0x03 TTS — raw mono 16-bit signed LE PCM (server → satellite)\n *\n * ⚠️ KEEP IN SYNC with the copy in the standalone `@iobroker/assistant-satellite` repo.\n */\n\nexport const TYPE_CONTROL = 0x01;\nexport const TYPE_AUDIO = 0x02;\nexport const TYPE_TTS = 0x03;\n\n/** Audio on the wire from the satellite is always 16 kHz, mono, 16-bit signed little-endian PCM. */\nexport const AUDIO_SAMPLE_RATE = 16000;\n\n/** Control messages sent by a satellite to the server. */\nexport type SatToServer =\n | { type: 'register'; device: string; room?: string; listen_port?: number }\n | { type: 'heartbeat'; device: string }\n | { type: 'audio_end'; device: string };\n\n/** Satellite lifecycle/status as reflected to the satellite (drives its LED) and to ioBroker states. */\nexport type SatelliteState = 'idle' | 'listening' | 'processing' | 'speaking';\n\n/**\n * Control messages sent by the server to a satellite.\n *\n * `listen` re-opens the microphone without a wake word, for the answer to a question the assistant just\n * asked (see `PendingQuestions`). It is additive: a satellite built before this message existed ignores\n * the unknown type and simply waits for its wake word instead.\n */\nexport type ServerToSat =\n | { type: 'registered'; ok: boolean }\n | { type: 'heartbeat_ack' }\n | { type: 'reregister' }\n | { type: 'tts_end'; sample_rate: number }\n | { type: 'status'; state: SatelliteState }\n | { type: 'listen' };\n\n/** Prefix a control JSON message with the CONTROL type byte. */\nexport function encodeControl(msg: ServerToSat): Buffer {\n return Buffer.concat([Buffer.from([TYPE_CONTROL]), Buffer.from(JSON.stringify(msg), 'utf8')]);\n}\n\n/** Prefix a raw PCM chunk with the TTS type byte. */\nexport function encodeTts(pcm: Buffer): Buffer {\n return Buffer.concat([Buffer.from([TYPE_TTS]), pcm]);\n}\n\n/** Split an incoming datagram into its type byte and payload (a view onto the original buffer). */\nexport function decodePacket(data: Buffer): { type: number; payload: Buffer } {\n return { type: data[0], payload: data.subarray(1) };\n}\n"]}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* A short confirmation beep, as an alternative to speaking "Okay" after a switch command.
|
|
4
|
+
*
|
|
5
|
+
* Every spoken confirmation costs a TTS round-trip — about a second between pressing the metaphorical
|
|
6
|
+
* switch and hearing that it worked, for information the user already expects. A 300 ms tone is instant
|
|
7
|
+
* and (once you know it) just as clear.
|
|
8
|
+
*
|
|
9
|
+
* Synthesised rather than shipped as a file, so it needs no asset, no decoder and no ffmpeg: a sine with
|
|
10
|
+
* a fast attack and an exponential decay, which is what the Python original uses
|
|
11
|
+
* (`C:\iot\Hannah`, `core/hannah/tts.py` `_synthesize_confirmation_tone`).
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.confirmationTone = confirmationTone;
|
|
15
|
+
/** Build the confirmation beep. Deterministic: the same options always give the same bytes. */
|
|
16
|
+
function confirmationTone(opts = {}) {
|
|
17
|
+
const sampleRate = opts.sampleRate ?? 24000;
|
|
18
|
+
const duration = Math.max(0.05, opts.duration ?? 0.3);
|
|
19
|
+
const freq = opts.freq ?? 1318.51;
|
|
20
|
+
const volume = Math.max(0, Math.min(1, opts.volume ?? 0.4));
|
|
21
|
+
const attack = 0.01; // a hard start would click
|
|
22
|
+
const samples = Math.round(sampleRate * duration);
|
|
23
|
+
const pcm = Buffer.alloc(samples * 2);
|
|
24
|
+
for (let i = 0; i < samples; i++) {
|
|
25
|
+
const t = i / sampleRate;
|
|
26
|
+
const envelope = t < attack ? t / attack : Math.exp((-12 * (t - attack)) / duration);
|
|
27
|
+
const value = Math.round(32767 * volume * envelope * Math.sin(2 * Math.PI * freq * t));
|
|
28
|
+
pcm.writeInt16LE(Math.max(-32768, Math.min(32767, value)), i * 2);
|
|
29
|
+
}
|
|
30
|
+
return { pcm, sampleRate };
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=tone.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tone.js","sourceRoot":"","sources":["../../../src/lib/voice/tone.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG;;AAmBH,4CAeC;AAhBD,+FAA+F;AAC/F,SAAgB,gBAAgB,CAAC,OAAoB,EAAE;IACnD,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,IAAI,KAAK,CAAC;IAC5C,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,IAAI,GAAG,CAAC,CAAC;IACtD,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,IAAI,OAAO,CAAC;IAClC,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC;IAC5D,MAAM,MAAM,GAAG,IAAI,CAAC,CAAC,2BAA2B;IAChD,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,QAAQ,CAAC,CAAC;IAClD,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC;IACtC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,EAAE,CAAC,EAAE,EAAE,CAAC;QAC/B,MAAM,CAAC,GAAG,CAAC,GAAG,UAAU,CAAC;QACzB,MAAM,QAAQ,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAC;QACrF,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC;QACvF,GAAG,CAAC,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACtE,CAAC;IACD,OAAO,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC;AAC/B,CAAC","sourcesContent":["/**\n * A short confirmation beep, as an alternative to speaking \"Okay\" after a switch command.\n *\n * Every spoken confirmation costs a TTS round-trip — about a second between pressing the metaphorical\n * switch and hearing that it worked, for information the user already expects. A 300 ms tone is instant\n * and (once you know it) just as clear.\n *\n * Synthesised rather than shipped as a file, so it needs no asset, no decoder and no ffmpeg: a sine with\n * a fast attack and an exponential decay, which is what the Python original uses\n * (`C:\\iot\\Hannah`, `core/hannah/tts.py` `_synthesize_confirmation_tone`).\n */\n\n/** Mono 16-bit signed little-endian PCM, like every other audio path in this adapter. */\nexport interface Tone {\n pcm: Buffer;\n sampleRate: number;\n}\n\nexport interface ToneOptions {\n /** Hz. The default is an E6 — high enough to cut through, short enough not to annoy. */\n freq?: number;\n /** Seconds. */\n duration?: number;\n /** 0…1, relative to full scale. */\n volume?: number;\n sampleRate?: number;\n}\n\n/** Build the confirmation beep. Deterministic: the same options always give the same bytes. */\nexport function confirmationTone(opts: ToneOptions = {}): Tone {\n const sampleRate = opts.sampleRate ?? 24000;\n const duration = Math.max(0.05, opts.duration ?? 0.3);\n const freq = opts.freq ?? 1318.51;\n const volume = Math.max(0, Math.min(1, opts.volume ?? 0.4));\n const attack = 0.01; // a hard start would click\n const samples = Math.round(sampleRate * duration);\n const pcm = Buffer.alloc(samples * 2);\n for (let i = 0; i < samples; i++) {\n const t = i / sampleRate;\n const envelope = t < attack ? t / attack : Math.exp((-12 * (t - attack)) / duration);\n const value = Math.round(32767 * volume * envelope * Math.sin(2 * Math.PI * freq * t));\n pcm.writeInt16LE(Math.max(-32768, Math.min(32767, value)), i * 2);\n }\n return { pcm, sampleRate };\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tts.js","sourceRoot":"","sources":["../../../src/lib/voice/tts.ts"],"names":[],"mappings":";;;;;;AAAA;;;GAGG;AACH,oDAA4B;
|
|
1
|
+
{"version":3,"file":"tts.js","sourceRoot":"","sources":["../../../src/lib/voice/tts.ts"],"names":[],"mappings":";;;;;;AAAA;;;GAGG;AACH,oDAA4B;AA2B5B;;;GAGG;AACH,MAAa,SAAS;IACD,MAAM,CAAS;IACf,KAAK,CAAc;IACnB,KAAK,CAAS;IAE/B,YAAY,MAAc,EAAE,QAAqB,OAAO,EAAE,KAAK,GAAG,OAAO;QACrE,IAAI,CAAC,MAAM,GAAG,IAAI,gBAAM,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;QACrC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACvB,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,IAAY,EAAE,KAAa;QACxC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC;YAC/C,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,KAAK,EAAE,IAAI;YACX,eAAe,EAAE,KAAK;SACzB,CAAC,CAAC;QACH,OAAO,EAAE,GAAG,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;IAC7E,CAAC;CACJ;AApBD,8BAoBC","sourcesContent":["/**\n * Text-to-speech engines. V1 ships the cloud engine (OpenAI TTS); local engines\n * (Piper / Wyoming) plug in behind the same `TtsEngine` interface later.\n */\nimport OpenAI from 'openai';\n\nexport interface TtsResult {\n /** Raw mono 16-bit signed LE PCM. */\n pcm: Buffer;\n sampleRate: number;\n}\n\nexport interface TtsEngine {\n synthesize(text: string, lang: string): Promise<TtsResult>;\n /**\n * Speak SSML (`<speak>…</speak>`) — only implemented by engines that understand it (Azure, Polly).\n * Callers go through `CachedTts`, which strips the markup for the engines that do not.\n */\n synthesizeSsml?(ssml: string, lang: string): Promise<TtsResult>;\n /** Optional warm-up (install/download engine + voice for `lang`) so the first request isn't slow. */\n prepare?(lang: string): Promise<void>;\n /**\n * Pre-synthesise phrases into whatever cache this engine has, returning how many were added.\n * Implemented by `CachedTts`; absent on a bare provider binding.\n */\n warm?(phrases: string[], lang: string): Promise<number>;\n}\n\n/** OpenAI TTS voice ids. */\nexport type OpenAiVoice = 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer';\n\n/**\n * Cloud TTS via the OpenAI speech API. Requests `pcm` output — 24 kHz mono 16-bit signed LE —\n * so no client-side decoding is needed; the satellite resamples to its speaker rate.\n */\nexport class OpenAiTts implements TtsEngine {\n private readonly client: OpenAI;\n private readonly voice: OpenAiVoice;\n private readonly model: string;\n\n constructor(apiKey: string, voice: OpenAiVoice = 'alloy', model = 'tts-1') {\n this.client = new OpenAI({ apiKey });\n this.voice = voice;\n this.model = model;\n }\n\n async synthesize(text: string, _lang: string): Promise<TtsResult> {\n const resp = await this.client.audio.speech.create({\n model: this.model,\n voice: this.voice,\n input: text,\n response_format: 'pcm',\n });\n return { pcm: Buffer.from(await resp.arrayBuffer()), sampleRate: 24000 };\n }\n}\n"]}
|