iobroker.assistant 1.3.0 → 1.4.0

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 +12 -0
  2. package/admin/custom/.vite/manifest.json +62 -62
  3. package/admin/custom/assets/{Components-x2Dfv8qE.js → Components-D15eOzOp.js} +2 -2
  4. package/admin/custom/assets/{_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-B4YbqhBb.js → _virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-C8qbMwoi.js} +1 -1
  5. package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-IaosaymG.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-BWAFmFMj.js} +1 -1
  6. package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-o4EU_Ut5.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-Bh5IbNwb.js} +1 -1
  7. package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare__shared--be2hWbV.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__loadShare__shared-D8mhEL60.js} +1 -1
  8. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-T80cpMmm.js +122 -0
  9. package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__-M61rjtmp.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__prebuild__react_mf_1_jsx_mf_2_runtime__prebuild__--SURtz9t.js} +1 -1
  10. package/admin/custom/assets/hostInit-DpzPHTOk.js +1 -0
  11. package/admin/custom/assets/{lib-DBbNtolG.js → lib-70DV4zKj.js} +1 -1
  12. package/admin/custom/assets/{mf-entry-bootstrap-0-7abd30d3.js → mf-entry-bootstrap-0-0119ee25.js} +2 -2
  13. package/admin/custom/assets/{pendingShares-U0D1MYK4.js → pendingShares-CKvyybtG.js} +1 -1
  14. package/admin/custom/customComponents.js +2 -2
  15. package/admin/custom/index.html +2 -2
  16. package/admin/custom/mf-manifest.json +1 -1
  17. package/admin/custom/mf-stats.json +1 -1
  18. package/admin/i18n/de.json +6 -1
  19. package/admin/i18n/en.json +6 -1
  20. package/admin/i18n/es.json +6 -1
  21. package/admin/i18n/fr.json +6 -1
  22. package/admin/i18n/it.json +6 -1
  23. package/admin/i18n/nl.json +6 -1
  24. package/admin/i18n/pl.json +6 -1
  25. package/admin/i18n/pt.json +6 -1
  26. package/admin/i18n/ru.json +6 -1
  27. package/admin/i18n/uk.json +6 -1
  28. package/admin/i18n/zh-cn.json +6 -1
  29. package/admin/jsonConfig.json +38 -4
  30. package/build/lib/nlu.js +14 -0
  31. package/build/lib/nlu.js.map +1 -1
  32. package/build/lib/piAgent.js +5 -0
  33. package/build/lib/piAgent.js.map +1 -1
  34. package/build/lib/voice/captures.js +87 -0
  35. package/build/lib/voice/captures.js.map +1 -0
  36. package/build/lib/voice/engines.js +1 -1
  37. package/build/lib/voice/engines.js.map +1 -1
  38. package/build/lib/voice/esphome.js +81 -5
  39. package/build/lib/voice/esphome.js.map +1 -1
  40. package/build/lib/voice/ttsCache.js +49 -1
  41. package/build/lib/voice/ttsCache.js.map +1 -1
  42. package/build/lib/voice/vad.js +14 -1
  43. package/build/lib/voice/vad.js.map +1 -1
  44. package/build/main.js +173 -63
  45. package/build/main.js.map +1 -1
  46. package/io-package.json +29 -27
  47. package/package.json +5 -5
  48. package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-BsTz_EWE.js +0 -122
  49. package/admin/custom/assets/hostInit-Blh7a-Qe.js +0 -1
  50. /package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BkH7fzDw.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__212234797318001__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-BkH7fzDw.js} +0 -0
@@ -11,6 +11,10 @@
11
11
  * prompt (style, follow-up marker, memory, device list) goes along as the `instructions` of that
12
12
  * conversation; what changes every turn (time, weather, presence) is prepended to the question.
13
13
  * pi-agent 0.3.0 and older ignore both fields and answer everything in their root conversation.
14
+ *
15
+ * With "Confirmation by text in sendTo conversations", pi-agent answers a write it may not do unasked with a
16
+ * question ending in `[[LISTEN]]`, and the next `ask` of the conversation is the yes or no. `language` makes it
17
+ * ask that question in the language the satellite listens in.
14
18
  */
15
19
  Object.defineProperty(exports, "__esModule", { value: true });
16
20
  exports.PiAgentHarness = exports.PI_AGENT_TIMEOUT_SEC = exports.PI_AGENT_MAX_INSTRUCTIONS = void 0;
@@ -72,6 +76,7 @@ class PiAgentHarness {
72
76
  text: req.text,
73
77
  conversation: conversationOf(req.conversation),
74
78
  instructions: req.instructions,
79
+ ...(req.language ? { language: req.language } : {}),
75
80
  requestId,
76
81
  }, { timeout: this.timeoutMs }));
77
82
  if (result?.error) {
@@ -1 +1 @@
1
- {"version":3,"file":"piAgent.js","sourceRoot":"","sources":["../../src/lib/piAgent.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The adapter `pi-agent` as the cloud tier (\"harness\"): instead of running its own tool loop, the assistant\n * hands the question to a pi-agent instance via `sendTo(<instance>, 'ask', …)`. pi-agent stores every model\n * and tool call as a checkpoint before it runs (an interrupted request continues after a restart, a write is\n * never repeated), enforces its own write permissions and confirmations, and can use the system-wide AI\n * settings of admin — none of which the built-in `LlmAgent` does.\n *\n * Each request source (satellite, `chat`, `telegram:<user>`) gets a conversation of its own in pi-agent\n * (`conversation`), so two rooms do not see each other's follow-ups. The source-independent part of the\n * prompt (style, follow-up marker, memory, device list) goes along as the `instructions` of that\n * conversation; what changes every turn (time, weather, presence) is prepended to the question.\n * pi-agent 0.3.0 and older ignore both fields and answer everything in their root conversation.\n */\n\n/** Longest instructions pi-agent accepts for a conversation; anything beyond is cut there. */\nexport const PI_AGENT_MAX_INSTRUCTIONS = 20000;\n\n/** Default for `piAgentTimeoutSec`: a tool loop with several reads can take a while. */\nexport const PI_AGENT_TIMEOUT_SEC = 60;\n\n/** The part of the adapter this module needs (kept small so tests can fake it). */\nexport interface PiAgentHost {\n sendToAsync(instance: string, command: string, message: unknown, options?: { timeout?: number }): Promise<unknown>;\n getForeignStateAsync(id: string): Promise<ioBroker.State | null | undefined>;\n}\n\nexport interface PiAgentRequest {\n /** The text pi-agent answers: context lines + the question. */\n text: string;\n /** Conversation of the caller, e.g. the satellite name. */\n conversation: string;\n /** Instructions of that conversation. */\n instructions: string;\n}\n\n/**\n * Join instruction blocks, leaving out optional ones from the end until the result fits into pi-agent's\n * limit — pi-agent would cut the text in the middle, and a half device list is worse than none.\n *\n * @param required blocks that always go along (style, follow-up marker)\n * @param optional blocks in order of importance (memory before the device list)\n */\nexport function composeInstructions(required: string[], optional: string[]): { text: string; dropped: number } {\n const blocks = [...required.filter(Boolean)];\n const extra = optional.filter(Boolean);\n let dropped = 0;\n while (extra.length) {\n const text = [...blocks, ...extra].join('\\n\\n');\n if (text.length <= PI_AGENT_MAX_INSTRUCTIONS) {\n return { text, dropped };\n }\n extra.pop();\n dropped++;\n }\n return { text: blocks.join('\\n\\n').slice(0, PI_AGENT_MAX_INSTRUCTIONS), dropped };\n}\n\n/** Conversation name for a request source; pi-agent uses \"/\" as separator in its keys. */\nexport function conversationOf(source: string): string {\n return (source || 'default').replace(/\\//g, '_').slice(0, 100);\n}\n\nexport class PiAgentHarness {\n private seq = 0;\n\n public constructor(\n private readonly host: PiAgentHost,\n /** e.g. `pi-agent.0` */\n public readonly instance: string,\n private readonly timeoutMs: number = PI_AGENT_TIMEOUT_SEC * 1000,\n ) {}\n\n /**\n * Ask pi-agent. Fails fast when the instance is not running (otherwise the caller would wait for the whole\n * timeout), and turns pi-agent's `{error}` into an exception so the caller can fall back to the local model.\n */\n public async ask(req: PiAgentRequest): Promise<string> {\n const alive = await this.host.getForeignStateAsync(`system.adapter.${this.instance}.alive`);\n if (!alive?.val) {\n throw new Error(`${this.instance} is not running`);\n }\n // Unique per request: pi-agent answers a repeated requestId only once.\n const requestId = `assistant-${Date.now().toString(36)}-${(++this.seq).toString(36)}`;\n const result = (await this.host.sendToAsync(\n this.instance,\n 'ask',\n {\n text: req.text,\n conversation: conversationOf(req.conversation),\n instructions: req.instructions,\n requestId,\n },\n { timeout: this.timeoutMs },\n )) as { answer?: unknown; error?: unknown } | null | undefined;\n if (result?.error) {\n const error = typeof result.error === 'string' ? result.error : JSON.stringify(result.error);\n throw new Error(`${this.instance}: ${error}`);\n }\n const answer = typeof result?.answer === 'string' ? result.answer.trim() : '';\n if (!answer) {\n throw new Error(`${this.instance} gave no answer`);\n }\n return answer;\n }\n}\n"]}
1
+ {"version":3,"file":"piAgent.js","sourceRoot":"","sources":["../../src/lib/piAgent.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The adapter `pi-agent` as the cloud tier (\"harness\"): instead of running its own tool loop, the assistant\n * hands the question to a pi-agent instance via `sendTo(<instance>, 'ask', …)`. pi-agent stores every model\n * and tool call as a checkpoint before it runs (an interrupted request continues after a restart, a write is\n * never repeated), enforces its own write permissions and confirmations, and can use the system-wide AI\n * settings of admin — none of which the built-in `LlmAgent` does.\n *\n * Each request source (satellite, `chat`, `telegram:<user>`) gets a conversation of its own in pi-agent\n * (`conversation`), so two rooms do not see each other's follow-ups. The source-independent part of the\n * prompt (style, follow-up marker, memory, device list) goes along as the `instructions` of that\n * conversation; what changes every turn (time, weather, presence) is prepended to the question.\n * pi-agent 0.3.0 and older ignore both fields and answer everything in their root conversation.\n *\n * With \"Confirmation by text in sendTo conversations\", pi-agent answers a write it may not do unasked with a\n * question ending in `[[LISTEN]]`, and the next `ask` of the conversation is the yes or no. `language` makes it\n * ask that question in the language the satellite listens in.\n */\n\n/** Longest instructions pi-agent accepts for a conversation; anything beyond is cut there. */\nexport const PI_AGENT_MAX_INSTRUCTIONS = 20000;\n\n/** Default for `piAgentTimeoutSec`: a tool loop with several reads can take a while. */\nexport const PI_AGENT_TIMEOUT_SEC = 60;\n\n/** The part of the adapter this module needs (kept small so tests can fake it). */\nexport interface PiAgentHost {\n sendToAsync(instance: string, command: string, message: unknown, options?: { timeout?: number }): Promise<unknown>;\n getForeignStateAsync(id: string): Promise<ioBroker.State | null | undefined>;\n}\n\nexport interface PiAgentRequest {\n /** The text pi-agent answers: context lines + the question. */\n text: string;\n /** Conversation of the caller, e.g. the satellite name. */\n conversation: string;\n /** Instructions of that conversation. */\n instructions: string;\n /**\n * Language the user speaks, e.g. `ru`. pi-agent asks its questions to confirm in it; older versions ask in the\n * system language, and a satellite listening in Russian does not hear a German \"Ja\" (the STT returns nothing).\n */\n language?: string;\n}\n\n/**\n * Join instruction blocks, leaving out optional ones from the end until the result fits into pi-agent's\n * limit — pi-agent would cut the text in the middle, and a half device list is worse than none.\n *\n * @param required blocks that always go along (style, follow-up marker)\n * @param optional blocks in order of importance (memory before the device list)\n */\nexport function composeInstructions(required: string[], optional: string[]): { text: string; dropped: number } {\n const blocks = [...required.filter(Boolean)];\n const extra = optional.filter(Boolean);\n let dropped = 0;\n while (extra.length) {\n const text = [...blocks, ...extra].join('\\n\\n');\n if (text.length <= PI_AGENT_MAX_INSTRUCTIONS) {\n return { text, dropped };\n }\n extra.pop();\n dropped++;\n }\n return { text: blocks.join('\\n\\n').slice(0, PI_AGENT_MAX_INSTRUCTIONS), dropped };\n}\n\n/** Conversation name for a request source; pi-agent uses \"/\" as separator in its keys. */\nexport function conversationOf(source: string): string {\n return (source || 'default').replace(/\\//g, '_').slice(0, 100);\n}\n\nexport class PiAgentHarness {\n private seq = 0;\n\n public constructor(\n private readonly host: PiAgentHost,\n /** e.g. `pi-agent.0` */\n public readonly instance: string,\n private readonly timeoutMs: number = PI_AGENT_TIMEOUT_SEC * 1000,\n ) {}\n\n /**\n * Ask pi-agent. Fails fast when the instance is not running (otherwise the caller would wait for the whole\n * timeout), and turns pi-agent's `{error}` into an exception so the caller can fall back to the local model.\n */\n public async ask(req: PiAgentRequest): Promise<string> {\n const alive = await this.host.getForeignStateAsync(`system.adapter.${this.instance}.alive`);\n if (!alive?.val) {\n throw new Error(`${this.instance} is not running`);\n }\n // Unique per request: pi-agent answers a repeated requestId only once.\n const requestId = `assistant-${Date.now().toString(36)}-${(++this.seq).toString(36)}`;\n const result = (await this.host.sendToAsync(\n this.instance,\n 'ask',\n {\n text: req.text,\n conversation: conversationOf(req.conversation),\n instructions: req.instructions,\n ...(req.language ? { language: req.language } : {}),\n requestId,\n },\n { timeout: this.timeoutMs },\n )) as { answer?: unknown; error?: unknown } | null | undefined;\n if (result?.error) {\n const error = typeof result.error === 'string' ? result.error : JSON.stringify(result.error);\n throw new Error(`${this.instance}: ${error}`);\n }\n const answer = typeof result?.answer === 'string' ? result.answer.trim() : '';\n if (!answer) {\n throw new Error(`${this.instance} gave no answer`);\n }\n return answer;\n }\n}\n"]}
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ /**
3
+ * Diagnosis recordings: microphone audio that a satellite captured but that never became a request — dropped as
4
+ * silence, or returned empty by the speech recognition. Without the audio there is no telling whether a false
5
+ * wake heard the TV, a quiet microphone missed a voice, or the recognition failed on real speech.
6
+ *
7
+ * Each recording is a WAV plus a JSON with what the voice activity detection made of it and a level profile (RMS
8
+ * per 100 ms), so the levels can be read without an audio editor. Only the newest {@link MAX_CAPTURES} are kept.
9
+ * The store does not care where files go; the adapter hands it its file storage (Admin → Files).
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.CaptureStore = exports.LEVEL_STEP_MS = exports.MAX_CAPTURES = exports.CAPTURE_DIR = void 0;
13
+ exports.levelProfile = levelProfile;
14
+ exports.captureBaseName = captureBaseName;
15
+ const stt_1 = require("./stt");
16
+ const vad_1 = require("./vad");
17
+ exports.CAPTURE_DIR = 'captures';
18
+ /** ~400 KB per 12 s recording at 16 kHz, so at most ~20 MB. */
19
+ exports.MAX_CAPTURES = 50;
20
+ /** Resolution of the level profile in the JSON. */
21
+ exports.LEVEL_STEP_MS = 100;
22
+ /** RMS per `stepMs` of mono 16-bit PCM, rounded. */
23
+ function levelProfile(pcm, sampleRate, stepMs = exports.LEVEL_STEP_MS) {
24
+ const step = Math.max(2, Math.round((sampleRate * stepMs) / 1000) * 2);
25
+ const levels = [];
26
+ for (let offset = 0; offset < pcm.length; offset += step) {
27
+ levels.push(Math.round((0, vad_1.rms)(pcm.subarray(offset, Math.min(pcm.length, offset + step)))));
28
+ }
29
+ return levels;
30
+ }
31
+ /**
32
+ * `2026-11-03_08-33-36-123_<device>_<reason>` in local time: sorts chronologically by name, which is what
33
+ * pruning relies on, and reads well in the Admin file list.
34
+ */
35
+ function captureBaseName(date, device, reason) {
36
+ const pad = (n, len = 2) => String(n).padStart(len, '0');
37
+ const time = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_` +
38
+ `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}-${pad(date.getMilliseconds(), 3)}`;
39
+ const name = (device || 'satellite').replace(/[^\w.-]/g, '_').slice(0, 60);
40
+ return `${time}_${name}_${reason}`;
41
+ }
42
+ class CaptureStore {
43
+ files;
44
+ max;
45
+ /** Saves run one after another, so two at once cannot prune each other's files. */
46
+ queue = Promise.resolve();
47
+ constructor(files, max = exports.MAX_CAPTURES) {
48
+ this.files = files;
49
+ this.max = max;
50
+ }
51
+ /** Store one recording and drop the oldest beyond the limit. Resolves with the path of the WAV. */
52
+ save(capture, now = new Date()) {
53
+ const run = this.queue.then(() => this.write(capture, now));
54
+ this.queue = run.catch(() => undefined);
55
+ return run;
56
+ }
57
+ async write(capture, now) {
58
+ const base = `${exports.CAPTURE_DIR}/${captureBaseName(now, capture.device, capture.reason)}`;
59
+ const info = {
60
+ time: now.toISOString(),
61
+ device: capture.device,
62
+ room: capture.room,
63
+ wakeWord: capture.wakeWord || '(follow-up)',
64
+ reason: capture.reason,
65
+ seconds: Math.round((capture.pcm.length / 2 / capture.sampleRate) * 100) / 100,
66
+ sampleRate: capture.sampleRate,
67
+ vad: capture.vad,
68
+ levelStepMs: exports.LEVEL_STEP_MS,
69
+ levels: levelProfile(capture.pcm, capture.sampleRate),
70
+ };
71
+ await this.files.write(`${base}.wav`, (0, stt_1.pcmToWav)(Buffer.from(capture.pcm), capture.sampleRate));
72
+ await this.files.write(`${base}.json`, `${JSON.stringify(info, null, 2)}\n`);
73
+ await this.prune();
74
+ return `${base}.wav`;
75
+ }
76
+ async prune() {
77
+ const names = (await this.files.list(exports.CAPTURE_DIR)).filter(n => /\.(wav|json)$/.test(n));
78
+ const bases = [...new Set(names.map(n => n.replace(/\.(wav|json)$/, '')))].sort();
79
+ for (const base of bases.slice(0, Math.max(0, bases.length - this.max))) {
80
+ for (const name of names.filter(n => n.replace(/\.(wav|json)$/, '') === base)) {
81
+ await this.files.remove(`${exports.CAPTURE_DIR}/${name}`).catch(() => undefined);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ exports.CaptureStore = CaptureStore;
87
+ //# sourceMappingURL=captures.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"captures.js","sourceRoot":"","sources":["../../../src/lib/voice/captures.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Diagnosis recordings: microphone audio that a satellite captured but that never became a request — dropped as\n * silence, or returned empty by the speech recognition. Without the audio there is no telling whether a false\n * wake heard the TV, a quiet microphone missed a voice, or the recognition failed on real speech.\n *\n * Each recording is a WAV plus a JSON with what the voice activity detection made of it and a level profile (RMS\n * per 100 ms), so the levels can be read without an audio editor. Only the newest {@link MAX_CAPTURES} are kept.\n * The store does not care where files go; the adapter hands it its file storage (Admin → Files).\n */\n\nimport { pcmToWav } from './stt';\nimport { rms, type Bytes } from './vad';\n\n/** Why a recording was dropped. */\nexport type DiscardReason =\n /** nobody started speaking before the start timeout */\n | 'no-speech'\n /** sound, but never loud enough for the speech threshold */\n | 'silence'\n /** speech by level, but the recognition returned no text */\n | 'empty-stt';\n\nexport interface DiscardedCapture {\n device: string;\n room: string;\n /** Wake word that opened the mic; '' = re-opened for a follow-up. */\n wakeWord: string;\n reason: DiscardReason;\n pcm: Bytes;\n sampleRate: number;\n vad: { peak: number; analysedPeak: number; noiseFloor: number; startLevel: number; endLevel: number };\n}\n\n/** File operations, relative to the store's root (the adapter's file storage). */\nexport interface CaptureFiles {\n write(path: string, data: Buffer | string): Promise<void>;\n /** File names in `dir`; empty (not an error) when it does not exist yet. */\n list(dir: string): Promise<string[]>;\n remove(path: string): Promise<void>;\n}\n\nexport const CAPTURE_DIR = 'captures';\n/** ~400 KB per 12 s recording at 16 kHz, so at most ~20 MB. */\nexport const MAX_CAPTURES = 50;\n/** Resolution of the level profile in the JSON. */\nexport const LEVEL_STEP_MS = 100;\n\n/** RMS per `stepMs` of mono 16-bit PCM, rounded. */\nexport function levelProfile(pcm: Bytes, sampleRate: number, stepMs = LEVEL_STEP_MS): number[] {\n const step = Math.max(2, Math.round((sampleRate * stepMs) / 1000) * 2);\n const levels: number[] = [];\n for (let offset = 0; offset < pcm.length; offset += step) {\n levels.push(Math.round(rms(pcm.subarray(offset, Math.min(pcm.length, offset + step)))));\n }\n return levels;\n}\n\n/**\n * `2026-11-03_08-33-36-123_<device>_<reason>` in local time: sorts chronologically by name, which is what\n * pruning relies on, and reads well in the Admin file list.\n */\nexport function captureBaseName(date: Date, device: string, reason: DiscardReason): string {\n const pad = (n: number, len = 2): string => String(n).padStart(len, '0');\n const time =\n `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_` +\n `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}-${pad(date.getMilliseconds(), 3)}`;\n const name = (device || 'satellite').replace(/[^\\w.-]/g, '_').slice(0, 60);\n return `${time}_${name}_${reason}`;\n}\n\nexport class CaptureStore {\n /** Saves run one after another, so two at once cannot prune each other's files. */\n private queue: Promise<unknown> = Promise.resolve();\n\n constructor(\n private readonly files: CaptureFiles,\n private readonly max = MAX_CAPTURES,\n ) {}\n\n /** Store one recording and drop the oldest beyond the limit. Resolves with the path of the WAV. */\n save(capture: DiscardedCapture, now = new Date()): Promise<string> {\n const run = this.queue.then(() => this.write(capture, now));\n this.queue = run.catch(() => undefined);\n return run;\n }\n\n private async write(capture: DiscardedCapture, now: Date): Promise<string> {\n const base = `${CAPTURE_DIR}/${captureBaseName(now, capture.device, capture.reason)}`;\n const info = {\n time: now.toISOString(),\n device: capture.device,\n room: capture.room,\n wakeWord: capture.wakeWord || '(follow-up)',\n reason: capture.reason,\n seconds: Math.round((capture.pcm.length / 2 / capture.sampleRate) * 100) / 100,\n sampleRate: capture.sampleRate,\n vad: capture.vad,\n levelStepMs: LEVEL_STEP_MS,\n levels: levelProfile(capture.pcm, capture.sampleRate),\n };\n await this.files.write(`${base}.wav`, pcmToWav(Buffer.from(capture.pcm), capture.sampleRate));\n await this.files.write(`${base}.json`, `${JSON.stringify(info, null, 2)}\\n`);\n await this.prune();\n return `${base}.wav`;\n }\n\n private async prune(): Promise<void> {\n const names = (await this.files.list(CAPTURE_DIR)).filter(n => /\\.(wav|json)$/.test(n));\n const bases = [...new Set(names.map(n => n.replace(/\\.(wav|json)$/, '')))].sort();\n for (const base of bases.slice(0, Math.max(0, bases.length - this.max))) {\n for (const name of names.filter(n => n.replace(/\\.(wav|json)$/, '') === base)) {\n await this.files.remove(`${CAPTURE_DIR}/${name}`).catch(() => undefined);\n }\n }\n }\n}\n"]}
@@ -140,7 +140,7 @@ function cachedTts(provider, ctx) {
140
140
  ? `${ctx.openrouterTtsModel || openrouter_1.DEFAULT_OPENROUTER_TTS_MODEL}-${ctx.voices.openrouter}`
141
141
  : ctx.voices.openai;
142
142
  const dir = path.join(ctx.dataDir, 'ttscache', `${provider}-${(voice || 'default').replace(/[^\w.-]/g, '_')}`);
143
- return new ttsCache_1.CachedTts(buildTtsEngine(provider, ctx), { dir, log: ctx.log });
143
+ return new ttsCache_1.CachedTts(buildTtsEngine(provider, ctx), { dir, log: ctx.log, maxChars: ctx.ttsMaxChars });
144
144
  }
145
145
  function buildTtsEngine(provider, ctx) {
146
146
  switch (provider) {
@@ -1 +1 @@
1
- {"version":3,"file":"engines.js","sourceRoot":"","sources":["../../../src/lib/voice/engines.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6FA,0CAYC;AAuBD,0CAeC;AAuDD,gCAmBC;AAGD,sCAKC;AAGD,sCAEC;AAtOD;;;;GAIG;AACH,+BAAkD;AAClD,+BAAoE;AACpE,mCAA8D;AAC9D,+BAAuE;AACvE,iCAAiD;AACjD,mCAAoD;AACpD,6CAOsB;AACtB,gDAAkC;AAElC,yCAAuC;AACvC,yCAAwE;AACxE,iCAAqC;AA8CrC,SAAS,YAAY,CAAC,KAAuB;IACzC,IAAI,CAAC,KAAK,CAAC,QAAQ,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QACxC,MAAM,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAC;IACxE,CAAC;AACL,CAAC;AAED,SAAS,UAAU,CAAC,KAAuB;IACvC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,eAAe,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC;QAC5E,MAAM,IAAI,KAAK,CAAC,iEAAiE,CAAC,CAAC;IACvF,CAAC;AACL,CAAC;AAED,SAAS,aAAa,CAAC,KAAuB;IAC1C,IAAI,CAAC,KAAK,CAAC,SAAS,EAAE,CAAC;QACnB,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;IACtD,CAAC;AACL,CAAC;AAED,SAAS,iBAAiB,CAAC,KAAuB;IAC9C,IAAI,CAAC,KAAK,CAAC,aAAa,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;IAC1D,CAAC;AACL,CAAC;AAED,SAAgB,eAAe,CAAC,QAAwB,EAAE,GAAkB;IACxE,MAAM,OAAO,GAA2B,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,cAAc,CAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,CAAC;IAClG,MAAM,QAAQ,GAAG,GAAG,CAAC,WAAW,IAAI,GAAG,CAAC,WAAW,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC;IACxF,IAAI,CAAC,QAAQ,EAAE,CAAC;QACZ,OAAO,OAAO,CAAC,MAAM,CAAC;IAC1B,CAAC;IACD,IAAI,CAAC;QACD,OAAO,IAAI,sBAAW,CAAC,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,cAAc,CAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;IAC1G,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACT,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,4BAA4B,QAAQ,kBAAmB,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;QAC3F,OAAO,OAAO,CAAC,MAAM,CAAC;IAC1B,CAAC;AACL,CAAC;AAED,SAAS,cAAc,CAAC,QAAwB,EAAE,GAAkB;IAChE,QAAQ,QAAQ,EAAE,CAAC;QACf,KAAK,OAAO;YACR,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACxB,OAAO,IAAI,gBAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;QACnE,KAAK,KAAK;YACN,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACtB,OAAO,IAAI,YAAM,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACrC,KAAK,MAAM;YACP,OAAO,IAAI,cAAO,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QAC5D,KAAK,YAAY;YACb,iBAAiB,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YAC7B,OAAO,IAAI,0BAAa,CAAC,GAAG,CAAC,KAAK,CAAC,aAAa,EAAE,GAAG,CAAC,kBAAkB,CAAC,CAAC;QAC9E,KAAK,OAAO;YACR,MAAM,IAAI,KAAK,CAAC,sDAAsD,CAAC,CAAC;QAC5E;YACI,aAAa,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACzB,OAAO,IAAI,eAAS,CAAC,GAAG,CAAC,KAAK,CAAC,SAAS,EAAE,GAAG,CAAC,QAAQ,IAAI,SAAS,CAAC,CAAC;IAC7E,CAAC;AACL,CAAC;AAED,SAAgB,eAAe,CAAC,QAAwB,EAAE,GAAkB;IACxE,sGAAsG;IACtG,iGAAiG;IACjG,MAAM,OAAO,GAA2B,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,CAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,CAAC;IAC7F,MAAM,QAAQ,GAAG,GAAG,CAAC,WAAW,IAAI,GAAG,CAAC,WAAW,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC;IACxF,IAAI,CAAC,QAAQ,EAAE,CAAC;QACZ,OAAO,OAAO,CAAC,MAAM,CAAC;IAC1B,CAAC;IACD,IAAI,CAAC;QACD,OAAO,IAAI,sBAAW,CAAC,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,CAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;IACrG,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACT,sGAAsG;QACtG,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,4BAA4B,QAAQ,kBAAmB,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC;QAC3F,OAAO,OAAO,CAAC,MAAM,CAAC;IAC1B,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,SAAS,SAAS,CAAC,QAAwB,EAAE,GAAkB;IAC3D,MAAM,KAAK,GACP,QAAQ,KAAK,OAAO;QAChB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK;QAClB,CAAC,CAAC,QAAQ,KAAK,KAAK;YAClB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG;YAChB,CAAC,CAAC,QAAQ,KAAK,OAAO;gBACpB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK;gBAClB,CAAC,CAAC,QAAQ,KAAK,YAAY;oBACzB,CAAC,CAAC,GAAG,GAAG,CAAC,kBAAkB,IAAI,yCAA4B,IAAI,GAAG,CAAC,MAAM,CAAC,UAAU,EAAE;oBACtF,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC;IAClC,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,QAAQ,IAAI,CAAC,KAAK,IAAI,SAAS,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;IAC/G,OAAO,IAAI,oBAAS,CAAC,cAAc,CAAC,QAAQ,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC;AAC/E,CAAC;AAED,SAAS,cAAc,CAAC,QAAwB,EAAE,GAAkB;IAChE,QAAQ,QAAQ,EAAE,CAAC;QACf,KAAK,OAAO;YACR,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACxB,OAAO,IAAI,gBAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,KAAK,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACrF,KAAK,KAAK;YACN,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACtB,OAAO,IAAI,YAAM,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACrD,KAAK,OAAO;YACR,OAAO,IAAI,gBAAQ,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QAChE,KAAK,MAAM;YACP,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;QAC3E,KAAK,YAAY;YACb,iBAAiB,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YAC7B,OAAO,IAAI,0BAAa,CACpB,GAAG,CAAC,KAAK,CAAC,aAAa,EACvB,GAAG,CAAC,kBAAkB,EACtB,GAAG,CAAC,MAAM,CAAC,UAAU,EACrB,GAAG,CAAC,WAAW,CAClB,CAAC;QACN;YACI,aAAa,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACzB,OAAO,IAAI,eAAS,CAChB,GAAG,CAAC,KAAK,CAAC,SAAS,EACnB,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,IAAI,OAAO,CAAgB,EAC7C,GAAG,CAAC,QAAQ,IAAI,SAAS,CAC5B,CAAC;IACV,CAAC;AACL,CAAC;AAED,uDAAuD;AACvD,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,CAAC;AAE5E,sFAAsF;AAC/E,KAAK,UAAU,UAAU,CAAC,QAAwB,EAAE,GAAkB,EAAE,QAAgB;IAC3F,QAAQ,QAAQ,EAAE,CAAC;QACf,KAAK,OAAO;YACR,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACxB,OAAO,IAAA,uBAAe,EAAC,GAAG,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,KAAK,CAAC,WAAW,EAAE,IAAA,kBAAW,EAAC,QAAQ,CAAC,CAAC,CAAC;QAC7F,KAAK,KAAK;YACN,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;YACtB,OAAO,IAAA,qBAAe,EAAC,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,IAAA,kBAAW,EAAC,QAAQ,CAAC,CAAC,CAAC;QACjE,KAAK,OAAO;YACR,OAAO,IAAA,uBAAe,GAAE,CAAC;QAC7B,KAAK,YAAY,CAAC,CAAC,CAAC;YAChB,6DAA6D;YAC7D,MAAM,KAAK,GAAG,GAAG,CAAC,kBAAkB,IAAI,yCAA4B,CAAC;YACrE,MAAM,MAAM,GAAG,MAAM,IAAA,uCAA0B,EAAC,QAAQ,CAAC,CAAC;YAC1D,OAAO,IAAA,8BAAiB,EAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,KAAK,CAAC,EAAE,MAAM,IAAI,EAAE,EAAE,QAAQ,CAAC,CAAC;QACvF,CAAC;QACD;YACI,OAAO,aAAa,CAAC;IAC7B,CAAC;AACL,CAAC;AAED,kHAAkH;AAC3G,KAAK,UAAU,aAAa,CAAC,QAAwB,EAAE,QAAgB;IAC1E,IAAI,QAAQ,KAAK,YAAY,EAAE,CAAC;QAC5B,OAAO,CAAC,MAAM,IAAA,uCAA0B,EAAC,eAAe,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAC9E,CAAC;IACD,OAAO,QAAQ,KAAK,MAAM,CAAC,CAAC,CAAC,IAAA,qBAAc,EAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AAC/D,CAAC;AAED,8FAA8F;AACvF,KAAK,UAAU,aAAa,CAAC,QAAwB;IACxD,OAAO,QAAQ,KAAK,YAAY,CAAC,CAAC,CAAC,CAAC,MAAM,IAAA,uCAA0B,EAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AACxG,CAAC","sourcesContent":["/**\n * Factory that builds the configured STT/TTS engine. Cloud engines (OpenAI/Azure/AWS) use resolved\n * credentials; local engines (Vosk/Piper) download their models on demand into the data dir.\n * Adding a provider = one new engine class + one `case` here.\n */\nimport { OpenAiStt, type SttEngine } from './stt';\nimport { OpenAiTts, type OpenAiVoice, type TtsEngine } from './tts';\nimport { AzureStt, AzureTts, listAzureVoices } from './azure';\nimport { AwsStt, AwsTts, listPollyVoices, type AwsCreds } from './aws';\nimport { VoskStt, listVoskModels } from './vosk';\nimport { PiperTts, listPiperVoices } from './piper';\nimport {\n OpenRouterStt,\n OpenRouterTts,\n listOpenRouterSpeechModels,\n voicesForLanguage,\n DEFAULT_OPENROUTER_TTS_MODEL,\n type AudioDecoder,\n} from './openrouter';\nimport * as path from 'node:path';\n\nimport { CachedTts } from './ttsCache';\nimport { FallbackStt, FallbackTts, type NamedEngine } from './fallback';\nimport { isoToLocale } from './lang';\nimport type { VoiceLogger } from './download';\n\nexport type SpeechProvider = 'openai' | 'openrouter' | 'azure' | 'aws' | 'vosk' | 'piper';\n\n/** All speech credentials, resolved from config (or the central credential store). */\nexport interface VoiceCredentials {\n openaiKey: string;\n openrouterKey: string;\n azureKey: string;\n azureRegion: string;\n aws: AwsCreds;\n}\n\n/** Provider-specific voice ids (each provider names voices differently). */\nexport interface VoiceNames {\n openai: string; // alloy/echo/…\n azure: string; // e.g. de-DE-KatjaNeural\n aws: string; // e.g. Vicki\n piper: string; // e.g. de_DE-thorsten-medium\n openrouter: string; // depends on the model, e.g. Kore, george\n}\n\n/** Everything the factory needs to build any engine. */\nexport interface EngineContext {\n creds: VoiceCredentials;\n voices: VoiceNames;\n /** Optional second engine, used when the primary one fails ('' = none). */\n sttFallback?: SpeechProvider | '';\n ttsFallback?: SpeechProvider | '';\n /** Writable instance data dir (for the local engines' downloads). */\n dataDir: string;\n log: VoiceLogger;\n /** Local model overrides ('' → default per language). */\n voskModel: string;\n /** OpenAI STT model ('' → 'whisper-1'; e.g. 'gpt-4o-transcribe'). */\n sttModel: string;\n /** OpenAI TTS model ('' → 'tts-1'; e.g. 'gpt-4o-mini-tts'). */\n ttsModel: string;\n /** OpenRouter models ('' → the defaults of openrouter.ts) */\n openrouterSttModel: string;\n openrouterTtsModel: string;\n /** Decoder for compressed speech (OpenRouter models that only return mp3); absent → those models fail */\n decodeAudio?: AudioDecoder;\n}\n\nfunction requireAzure(creds: VoiceCredentials): void {\n if (!creds.azureKey || !creds.azureRegion) {\n throw new Error('Azure speech needs a subscription key and region');\n }\n}\n\nfunction requireAws(creds: VoiceCredentials): void {\n if (!creds.aws.accessKeyId || !creds.aws.secretAccessKey || !creds.aws.region) {\n throw new Error('AWS speech needs an access key id, secret access key and region');\n }\n}\n\nfunction requireOpenAi(creds: VoiceCredentials): void {\n if (!creds.openaiKey) {\n throw new Error('OpenAI speech needs an API key');\n }\n}\n\nfunction requireOpenRouter(creds: VoiceCredentials): void {\n if (!creds.openrouterKey) {\n throw new Error('OpenRouter speech needs an API key');\n }\n}\n\nexport function createSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n const primary: NamedEngine<SttEngine> = { name: provider, engine: buildSttEngine(provider, ctx) };\n const fallback = ctx.sttFallback && ctx.sttFallback !== provider ? ctx.sttFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackStt([primary, { name: fallback, engine: buildSttEngine(fallback, ctx) }], ctx.log);\n } catch (e) {\n ctx.log.warn(`Speech-to-text fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\nfunction buildSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureStt(ctx.creds.azureKey, ctx.creds.azureRegion);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsStt(ctx.creds.aws);\n case 'vosk':\n return new VoskStt(ctx.dataDir, ctx.voskModel, ctx.log);\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterStt(ctx.creds.openrouterKey, ctx.openrouterSttModel);\n case 'piper':\n throw new Error('Piper is a text-to-speech engine, not speech-to-text');\n default:\n requireOpenAi(ctx.creds);\n return new OpenAiStt(ctx.creds.openaiKey, ctx.sttModel || undefined);\n }\n}\n\nexport function createTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n // Each engine gets the shared behaviour (disk cache, length limit, SSML) and its own cache directory,\n // so a fallback engine's voice can never be served later while the primary one is healthy again.\n const primary: NamedEngine<TtsEngine> = { name: provider, engine: cachedTts(provider, ctx) };\n const fallback = ctx.ttsFallback && ctx.ttsFallback !== provider ? ctx.ttsFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackTts([primary, { name: fallback, engine: cachedTts(fallback, ctx) }], ctx.log);\n } catch (e) {\n // A fallback that cannot even be built (missing key, wrong provider) must not take the main one down.\n ctx.log.warn(`Text-to-speech fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\n/**\n * One provider binding plus the shared cache/length/SSML layer. One cache directory per provider+voice —\n * a voice change must not serve audio in the old voice.\n */\nfunction cachedTts(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n const voice =\n provider === 'azure'\n ? ctx.voices.azure\n : provider === 'aws'\n ? ctx.voices.aws\n : provider === 'piper'\n ? ctx.voices.piper\n : provider === 'openrouter'\n ? `${ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL}-${ctx.voices.openrouter}`\n : ctx.voices.openai;\n const dir = path.join(ctx.dataDir, 'ttscache', `${provider}-${(voice || 'default').replace(/[^\\w.-]/g, '_')}`);\n return new CachedTts(buildTtsEngine(provider, ctx), { dir, log: ctx.log });\n}\n\nfunction buildTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureTts(ctx.creds.azureKey, ctx.creds.azureRegion, ctx.voices.azure);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsTts(ctx.creds.aws, ctx.voices.aws);\n case 'piper':\n return new PiperTts(ctx.dataDir, ctx.voices.piper, ctx.log);\n case 'vosk':\n throw new Error('Vosk is a speech-to-text engine, not text-to-speech');\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterTts(\n ctx.creds.openrouterKey,\n ctx.openrouterTtsModel,\n ctx.voices.openrouter,\n ctx.decodeAudio,\n );\n default:\n requireOpenAi(ctx.creds);\n return new OpenAiTts(\n ctx.creds.openaiKey,\n (ctx.voices.openai || 'alloy') as OpenAiVoice,\n ctx.ttsModel || undefined,\n );\n }\n}\n\n/** OpenAI TTS voices are a fixed set (no list API). */\nconst OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];\n\n/** List the available TTS voices for a provider (for the settings voice dropdown). */\nexport async function listVoices(provider: SpeechProvider, ctx: EngineContext, language: string): Promise<string[]> {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return listAzureVoices(ctx.creds.azureKey, ctx.creds.azureRegion, isoToLocale(language));\n case 'aws':\n requireAws(ctx.creds);\n return listPollyVoices(ctx.creds.aws, isoToLocale(language));\n case 'piper':\n return listPiperVoices();\n case 'openrouter': {\n // public list, no key needed: the voices of the chosen model\n const model = ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL;\n const models = await listOpenRouterSpeechModels('speech');\n return voicesForLanguage(models.find(m => m.id === model)?.voices || [], language);\n }\n default:\n return OPENAI_VOICES;\n }\n}\n\n/** Suggested STT model names for the settings dropdown: the local Vosk catalogue, or the models of OpenRouter. */\nexport async function listSttModels(provider: SpeechProvider, language: string): Promise<string[]> {\n if (provider === 'openrouter') {\n return (await listOpenRouterSpeechModels('transcription')).map(m => m.id);\n }\n return provider === 'vosk' ? listVoskModels(language) : [];\n}\n\n/** TTS model names for the settings dropdown — only OpenRouter has a choice worth listing. */\nexport async function listTtsModels(provider: SpeechProvider): Promise<string[]> {\n return provider === 'openrouter' ? (await listOpenRouterSpeechModels('speech')).map(m => m.id) : [];\n}\n"]}
1
+ {"version":3,"file":"engines.js","sourceRoot":"","sources":["../../../src/lib/voice/engines.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Factory that builds the configured STT/TTS engine. Cloud engines (OpenAI/Azure/AWS) use resolved\n * credentials; local engines (Vosk/Piper) download their models on demand into the data dir.\n * Adding a provider = one new engine class + one `case` here.\n */\nimport { OpenAiStt, type SttEngine } from './stt';\nimport { OpenAiTts, type OpenAiVoice, type TtsEngine } from './tts';\nimport { AzureStt, AzureTts, listAzureVoices } from './azure';\nimport { AwsStt, AwsTts, listPollyVoices, type AwsCreds } from './aws';\nimport { VoskStt, listVoskModels } from './vosk';\nimport { PiperTts, listPiperVoices } from './piper';\nimport {\n OpenRouterStt,\n OpenRouterTts,\n listOpenRouterSpeechModels,\n voicesForLanguage,\n DEFAULT_OPENROUTER_TTS_MODEL,\n type AudioDecoder,\n} from './openrouter';\nimport * as path from 'node:path';\n\nimport { CachedTts } from './ttsCache';\nimport { FallbackStt, FallbackTts, type NamedEngine } from './fallback';\nimport { isoToLocale } from './lang';\nimport type { VoiceLogger } from './download';\n\nexport type SpeechProvider = 'openai' | 'openrouter' | 'azure' | 'aws' | 'vosk' | 'piper';\n\n/** All speech credentials, resolved from config (or the central credential store). */\nexport interface VoiceCredentials {\n openaiKey: string;\n openrouterKey: string;\n azureKey: string;\n azureRegion: string;\n aws: AwsCreds;\n}\n\n/** Provider-specific voice ids (each provider names voices differently). */\nexport interface VoiceNames {\n openai: string; // alloy/echo/…\n azure: string; // e.g. de-DE-KatjaNeural\n aws: string; // e.g. Vicki\n piper: string; // e.g. de_DE-thorsten-medium\n openrouter: string; // depends on the model, e.g. Kore, george\n}\n\n/** Everything the factory needs to build any engine. */\nexport interface EngineContext {\n creds: VoiceCredentials;\n voices: VoiceNames;\n /** Optional second engine, used when the primary one fails ('' = none). */\n sttFallback?: SpeechProvider | '';\n ttsFallback?: SpeechProvider | '';\n /** Writable instance data dir (for the local engines' downloads). */\n dataDir: string;\n log: VoiceLogger;\n /** Local model overrides ('' → default per language). */\n voskModel: string;\n /** OpenAI STT model ('' → 'whisper-1'; e.g. 'gpt-4o-transcribe'). */\n sttModel: string;\n /** OpenAI TTS model ('' → 'tts-1'; e.g. 'gpt-4o-mini-tts'). */\n ttsModel: string;\n /** OpenRouter models ('' → the defaults of openrouter.ts) */\n openrouterSttModel: string;\n openrouterTtsModel: string;\n /** Decoder for compressed speech (OpenRouter models that only return mp3); absent → those models fail */\n decodeAudio?: AudioDecoder;\n /** Longest text that is spoken, in characters; longer ones are cut at a sentence end. 0 = no limit, absent = 400. */\n ttsMaxChars?: number;\n}\n\nfunction requireAzure(creds: VoiceCredentials): void {\n if (!creds.azureKey || !creds.azureRegion) {\n throw new Error('Azure speech needs a subscription key and region');\n }\n}\n\nfunction requireAws(creds: VoiceCredentials): void {\n if (!creds.aws.accessKeyId || !creds.aws.secretAccessKey || !creds.aws.region) {\n throw new Error('AWS speech needs an access key id, secret access key and region');\n }\n}\n\nfunction requireOpenAi(creds: VoiceCredentials): void {\n if (!creds.openaiKey) {\n throw new Error('OpenAI speech needs an API key');\n }\n}\n\nfunction requireOpenRouter(creds: VoiceCredentials): void {\n if (!creds.openrouterKey) {\n throw new Error('OpenRouter speech needs an API key');\n }\n}\n\nexport function createSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n const primary: NamedEngine<SttEngine> = { name: provider, engine: buildSttEngine(provider, ctx) };\n const fallback = ctx.sttFallback && ctx.sttFallback !== provider ? ctx.sttFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackStt([primary, { name: fallback, engine: buildSttEngine(fallback, ctx) }], ctx.log);\n } catch (e) {\n ctx.log.warn(`Speech-to-text fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\nfunction buildSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureStt(ctx.creds.azureKey, ctx.creds.azureRegion);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsStt(ctx.creds.aws);\n case 'vosk':\n return new VoskStt(ctx.dataDir, ctx.voskModel, ctx.log);\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterStt(ctx.creds.openrouterKey, ctx.openrouterSttModel);\n case 'piper':\n throw new Error('Piper is a text-to-speech engine, not speech-to-text');\n default:\n requireOpenAi(ctx.creds);\n return new OpenAiStt(ctx.creds.openaiKey, ctx.sttModel || undefined);\n }\n}\n\nexport function createTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n // Each engine gets the shared behaviour (disk cache, length limit, SSML) and its own cache directory,\n // so a fallback engine's voice can never be served later while the primary one is healthy again.\n const primary: NamedEngine<TtsEngine> = { name: provider, engine: cachedTts(provider, ctx) };\n const fallback = ctx.ttsFallback && ctx.ttsFallback !== provider ? ctx.ttsFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackTts([primary, { name: fallback, engine: cachedTts(fallback, ctx) }], ctx.log);\n } catch (e) {\n // A fallback that cannot even be built (missing key, wrong provider) must not take the main one down.\n ctx.log.warn(`Text-to-speech fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\n/**\n * One provider binding plus the shared cache/length/SSML layer. One cache directory per provider+voice —\n * a voice change must not serve audio in the old voice.\n */\nfunction cachedTts(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n const voice =\n provider === 'azure'\n ? ctx.voices.azure\n : provider === 'aws'\n ? ctx.voices.aws\n : provider === 'piper'\n ? ctx.voices.piper\n : provider === 'openrouter'\n ? `${ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL}-${ctx.voices.openrouter}`\n : ctx.voices.openai;\n const dir = path.join(ctx.dataDir, 'ttscache', `${provider}-${(voice || 'default').replace(/[^\\w.-]/g, '_')}`);\n return new CachedTts(buildTtsEngine(provider, ctx), { dir, log: ctx.log, maxChars: ctx.ttsMaxChars });\n}\n\nfunction buildTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureTts(ctx.creds.azureKey, ctx.creds.azureRegion, ctx.voices.azure);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsTts(ctx.creds.aws, ctx.voices.aws);\n case 'piper':\n return new PiperTts(ctx.dataDir, ctx.voices.piper, ctx.log);\n case 'vosk':\n throw new Error('Vosk is a speech-to-text engine, not text-to-speech');\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterTts(\n ctx.creds.openrouterKey,\n ctx.openrouterTtsModel,\n ctx.voices.openrouter,\n ctx.decodeAudio,\n );\n default:\n requireOpenAi(ctx.creds);\n return new OpenAiTts(\n ctx.creds.openaiKey,\n (ctx.voices.openai || 'alloy') as OpenAiVoice,\n ctx.ttsModel || undefined,\n );\n }\n}\n\n/** OpenAI TTS voices are a fixed set (no list API). */\nconst OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];\n\n/** List the available TTS voices for a provider (for the settings voice dropdown). */\nexport async function listVoices(provider: SpeechProvider, ctx: EngineContext, language: string): Promise<string[]> {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return listAzureVoices(ctx.creds.azureKey, ctx.creds.azureRegion, isoToLocale(language));\n case 'aws':\n requireAws(ctx.creds);\n return listPollyVoices(ctx.creds.aws, isoToLocale(language));\n case 'piper':\n return listPiperVoices();\n case 'openrouter': {\n // public list, no key needed: the voices of the chosen model\n const model = ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL;\n const models = await listOpenRouterSpeechModels('speech');\n return voicesForLanguage(models.find(m => m.id === model)?.voices || [], language);\n }\n default:\n return OPENAI_VOICES;\n }\n}\n\n/** Suggested STT model names for the settings dropdown: the local Vosk catalogue, or the models of OpenRouter. */\nexport async function listSttModels(provider: SpeechProvider, language: string): Promise<string[]> {\n if (provider === 'openrouter') {\n return (await listOpenRouterSpeechModels('transcription')).map(m => m.id);\n }\n return provider === 'vosk' ? listVoskModels(language) : [];\n}\n\n/** TTS model names for the settings dropdown — only OpenRouter has a choice worth listing. */\nexport async function listTtsModels(provider: SpeechProvider): Promise<string[]> {\n return provider === 'openrouter' ? (await listOpenRouterSpeechModels('speech')).map(m => m.id) : [];\n}\n"]}
@@ -33,7 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.EsphomeSatellites = exports.TIMER_EVENT = void 0;
36
+ exports.EsphomeSatellites = exports.TIMER_EVENT = exports.DEFAULT_SPEECH_LEVEL = void 0;
37
+ exports.speechLevelOf = speechLevelOf;
37
38
  /**
38
39
  * ESPHome voice satellites — the fourth transport, next to the UDP server (`voiceServer.ts`) and the
39
40
  * Wyoming endpoint (`wyoming.ts`).
@@ -110,6 +111,31 @@ const RECONNECT_MS = 10_000;
110
111
  const PIPELINE_TIMEOUT_MS = 30_000;
111
112
  /** How long we wait for the device to report that it finished playing before forcing it back to idle. */
112
113
  const PLAYBACK_TIMEOUT_MS = 60_000;
114
+ /**
115
+ * Default lowest RMS that counts as speech (the noise floor can only raise the gate above it). Fits the
116
+ * ThirdReality speaker; quieter microphones get their own value per device ({@link EsphomeDevice.speechLevel}).
117
+ */
118
+ exports.DEFAULT_SPEECH_LEVEL = 250;
119
+ /**
120
+ * Close the microphone when nobody has started speaking this long after the wake word. Without it a false wake
121
+ * kept the stream open for the full 12 s cap: on a ThirdReality speaker with a custom wake word that was ten
122
+ * times in one night, each 12.6 s of an open mic (and of music ducked, if any played).
123
+ */
124
+ const NO_SPEECH_MS = 5000;
125
+ /** The same after a follow-up question re-opened the mic: the user may need a moment to think. */
126
+ const NO_SPEECH_FOLLOW_UP_MS = 8000;
127
+ /** Silence threshold as a share of the speech threshold — keeps the default pair 250/150. */
128
+ const SILENCE_SHARE = 0.6;
129
+ /**
130
+ * A dropped utterance whose loudest frame reached this share of the speech threshold was probably a voice
131
+ * that was too quiet, not an empty room — worth telling the user where to lower the threshold.
132
+ */
133
+ const QUIET_VOICE_SHARE = 0.4;
134
+ /** The configured speech threshold of a device, or 0 for the default. Table cells may arrive as strings. */
135
+ function speechLevelOf(device) {
136
+ const level = Math.round(Number(device.speechLevel));
137
+ return Number.isFinite(level) && level > 0 ? level : 0;
138
+ }
113
139
  /** `VoiceAssistantTimerEvent` in api.proto. */
114
140
  exports.TIMER_EVENT = { started: 0, updated: 1, cancelled: 2, finished: 3 };
115
141
  /**
@@ -237,12 +263,15 @@ class EsphomeConnection {
237
263
  capture = null;
238
264
  playbackTimer = null;
239
265
  password;
266
+ /** Configured speech threshold, 0 = {@link DEFAULT_SPEECH_LEVEL}. */
267
+ speechLevel;
240
268
  constructor(device, opts) {
241
269
  this.opts = opts;
242
270
  this.host = device.ip.trim();
243
271
  this.port = device.port || DEFAULT_PORT;
244
272
  this.room = (device.room || '').trim();
245
273
  this.password = device.password || '';
274
+ this.speechLevel = speechLevelOf(device);
246
275
  this.deviceName = this.host;
247
276
  }
248
277
  // ── lifecycle ───────────────────────────────────────────────────────────────────────────────────
@@ -570,6 +599,9 @@ class EsphomeConnection {
570
599
  // travel: derive them from the room instead, once the wake chirp is out of the way.
571
600
  skipMs: WAKE_CHIRP_MS,
572
601
  adaptive: true,
602
+ startTimeoutMs: phrase ? NO_SPEECH_MS : NO_SPEECH_FOLLOW_UP_MS,
603
+ minStartLevel: this.speechLevel || exports.DEFAULT_SPEECH_LEVEL,
604
+ minEndLevel: Math.round((this.speechLevel || exports.DEFAULT_SPEECH_LEVEL) * SILENCE_SHARE),
573
605
  }),
574
606
  conversationId: request.getConversationId(),
575
607
  wakeWord: phrase || '',
@@ -592,8 +624,9 @@ class EsphomeConnection {
592
624
  const chunk = Buffer.from(audio.getData_asU8());
593
625
  if (chunk.length) {
594
626
  capture.chunks.push(chunk);
595
- if (capture.detector.push(chunk)) {
596
- void this.finishCapture();
627
+ const end = capture.detector.push(chunk);
628
+ if (end) {
629
+ void this.finishCapture(end);
597
630
  return;
598
631
  }
599
632
  }
@@ -602,7 +635,34 @@ class EsphomeConnection {
602
635
  }
603
636
  }
604
637
  /** Stop the mic stream and run STT → answer → TTS for what was captured. */
605
- async finishCapture() {
638
+ /** Hand a recording that never became a request to {@link EsphomeSatellitesOptions.onDiscarded}. */
639
+ discard(capture, pcm, reason) {
640
+ if (!this.opts.onDiscarded || !pcm.length) {
641
+ return;
642
+ }
643
+ const d = capture.detector;
644
+ try {
645
+ this.opts.onDiscarded({
646
+ device: this.deviceName,
647
+ room: this.room,
648
+ wakeWord: capture.wakeWord,
649
+ reason,
650
+ pcm,
651
+ sampleRate: MIC_SAMPLE_RATE,
652
+ vad: {
653
+ peak: Math.round(d.peak),
654
+ analysedPeak: Math.round(d.analysedPeak),
655
+ noiseFloor: Math.round(d.noiseFloor),
656
+ startLevel: Math.round(d.startLevel),
657
+ endLevel: Math.round(d.endLevel),
658
+ },
659
+ });
660
+ }
661
+ catch (e) {
662
+ this.opts.log.debug(`ESPHome ${this.deviceName}: keeping the recording failed — ${e.message}`);
663
+ }
664
+ }
665
+ async finishCapture(end) {
606
666
  const capture = this.capture;
607
667
  if (!capture || capture.closed) {
608
668
  return;
@@ -618,7 +678,19 @@ class EsphomeConnection {
618
678
  `(peak RMS ${Math.round(capture.detector.peak)}, noise floor ${Math.round(capture.detector.noiseFloor)}, ` +
619
679
  `speech/silence gate ${Math.round(capture.detector.startLevel)}/${Math.round(capture.detector.endLevel)})`);
620
680
  if (!pcm.length || !capture.detector.sawSpeech) {
621
- this.opts.log.debug(`ESPHome ${this.deviceName}: nothing but silence — dropping the utterance`);
681
+ const loudest = Math.round(capture.detector.analysedPeak);
682
+ const gate = Math.round(capture.detector.startLevel);
683
+ if (pcm.length && loudest >= gate * QUIET_VOICE_SHARE) {
684
+ // Not an empty room: something came close to the gate. Typically a microphone that is quieter
685
+ // than the default threshold expects (a phone) — say where to change it.
686
+ this.opts.log.info(`ESPHome ${this.deviceName}: dropped the utterance — the loudest sound (RMS ${loudest}) stayed ` +
687
+ `below the speech threshold ${gate}. If somebody spoke, lower "Speech threshold" for this ` +
688
+ `device in the ESPHome satellites table (Voice tab), e.g. to ${Math.max(30, Math.round((loudest * 0.6) / 10) * 10)}.`);
689
+ }
690
+ else {
691
+ this.opts.log.debug(`ESPHome ${this.deviceName}: nothing but silence${end === 'no-speech' ? ' (nobody started speaking)' : ''} — dropping the utterance`);
692
+ }
693
+ this.discard(capture, pcm, end === 'no-speech' ? 'no-speech' : 'silence');
622
694
  this.event(EVENT.RUN_END);
623
695
  this.status('idle');
624
696
  return;
@@ -631,6 +703,10 @@ class EsphomeConnection {
631
703
  const sttMs = Date.now() - sttStart;
632
704
  this.event(EVENT.STT_END, { text });
633
705
  if (!text) {
706
+ // Speech was heard, so the user waits for an answer: say why none comes. Typically a word in another
707
+ // language than the STT listens in (a German "Ja" to a Russian STT comes back empty).
708
+ this.opts.log.info(`Voice Q (${this.deviceName}): (empty) — speech recognition returned no text for ${seconds.toFixed(1)} s of audio`);
709
+ this.discard(capture, pcm, 'empty-stt');
634
710
  this.event(EVENT.RUN_END);
635
711
  this.status('idle');
636
712
  return;