iobroker.assistant 1.3.1 → 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 (46) hide show
  1. package/README.md +6 -0
  2. package/admin/custom/.vite/manifest.json +62 -62
  3. package/admin/custom/assets/{Components-D77NmS_I.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/voice/captures.js +87 -0
  31. package/build/lib/voice/captures.js.map +1 -0
  32. package/build/lib/voice/engines.js +1 -1
  33. package/build/lib/voice/engines.js.map +1 -1
  34. package/build/lib/voice/esphome.js +78 -5
  35. package/build/lib/voice/esphome.js.map +1 -1
  36. package/build/lib/voice/ttsCache.js +49 -1
  37. package/build/lib/voice/ttsCache.js.map +1 -1
  38. package/build/lib/voice/vad.js +14 -1
  39. package/build/lib/voice/vad.js.map +1 -1
  40. package/build/main.js +54 -5
  41. package/build/main.js.map +1 -1
  42. package/io-package.json +16 -14
  43. package/package.json +5 -5
  44. 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
  45. package/admin/custom/assets/hostInit-Blh7a-Qe.js +0 -1
  46. /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
@@ -1 +1 @@
1
- {"version":3,"file":"ttsCache.js","sourceRoot":"","sources":["../../../src/lib/voice/ttsCache.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The plumbing every TTS engine benefits from: a disk cache, a length limit, and SSML handling.\n *\n * Wrapped around any {@link TtsEngine}, so each engine stays a thin provider binding:\n * - **cache** — the assistant says the same short things over and over (\"Ok.\", \"Timer abgelaufen\",\n * a trigger's announcement). Synthesising those again on every repeat costs a cloud round-trip and\n * a few hundred milliseconds of latency for audio that is byte-for-byte identical. Only short texts\n * are cached: a long one-off answer will never be asked for twice, so it would only fill the disk.\n * - **length limit** — an LLM can answer with three paragraphs. Spoken at a speaker that is a minute\n * of monologue nobody can interrupt, so the text is cut at the last sentence end that fits.\n * - **SSML** — Azure and Polly can speak `<speak>…</speak>` (pauses, emphasis); OpenAI and the local\n * engines cannot, so there the tags are stripped rather than read out loud.\n *\n * All three come from the Python original (`C:\\iot\\Hannah`, `core/hannah/tts.py`: `_TTSCache`,\n * `_truncate_for_tts`, `_strip_ssml_tags`), including the detail that the sample rate belongs in the\n * cache entry — serving 24 kHz audio as 16 kHz plays it back at the wrong pitch.\n */\nimport * as crypto from 'node:crypto';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport type { TtsEngine, TtsResult } from './tts';\nimport type { VoiceLogger } from './download';\n\n/** Hard limit for a spoken text. Hannah's 400 characters ≈ 25 seconds of speech. */\nexport const MAX_TTS_CHARS = 400;\n\n/** Texts up to this length are worth caching; a longer one is a one-off answer. */\nexport const MAX_CACHE_CHARS = 200;\n\n/** Default budget for the cache directory. ~1 MB per 20 s of 24 kHz 16-bit audio. */\nexport const DEFAULT_CACHE_BYTES = 64 * 1024 * 1024;\n\n/** Does this text ask for SSML? */\nexport function isSsml(text: string): boolean {\n return /^\\s*<speak[\\s>]/i.test(text || '');\n}\n\n/** Drop the XML tags, for an engine that cannot speak SSML (reading them aloud would be worse). */\nexport function stripSsml(text: string): string {\n return (text || '')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/**\n * Cut `text` to `maxChars`, preferring the last sentence end in the second half so the result does not\n * stop mid-thought; failing that, the last word boundary plus an ellipsis. SSML is left alone — cutting\n * it would break the markup, and it is written by us, not by a model.\n */\nexport function truncateForTts(text: string, maxChars = MAX_TTS_CHARS): string {\n const t = (text || '').trim();\n if (t.length <= maxChars || isSsml(t)) {\n return t;\n }\n const chunk = t.slice(0, maxChars);\n for (const sep of ['.', '!', '?', '…']) {\n const idx = chunk.lastIndexOf(sep);\n if (idx > maxChars / 2) {\n return chunk.slice(0, idx + 1);\n }\n }\n const lastSpace = chunk.lastIndexOf(' ');\n return `${(lastSpace > 0 ? chunk.slice(0, lastSpace) : chunk).trim()} …`;\n}\n\n/** A cache entry on disk: the sample rate first, so an entry can never be replayed at the wrong pitch. */\nfunction encodeEntry(result: TtsResult): Buffer {\n const head = Buffer.alloc(4);\n head.writeUInt32LE(result.sampleRate, 0);\n return Buffer.concat([head, result.pcm]);\n}\n\nfunction decodeEntry(data: Buffer): TtsResult | null {\n if (data.length < 8) {\n return null;\n }\n const sampleRate = data.readUInt32LE(0);\n // A plausible speech rate; anything else means the file is not one of ours (or is truncated).\n if (sampleRate < 8000 || sampleRate > 48000) {\n return null;\n }\n return { pcm: data.subarray(4), sampleRate };\n}\n\nexport interface TtsCacheOptions {\n /** Directory for this engine's entries (one per provider+voice, so a voice change cannot serve stale audio). */\n dir: string;\n log: VoiceLogger;\n /** Cut texts longer than this before synthesising (0 = no limit). */\n maxChars?: number;\n /** Only cache texts up to this length (0 = cache everything). */\n maxCacheChars?: number;\n /** Prune the oldest entries once the directory exceeds this (0 = no limit). */\n maxBytes?: number;\n}\n\n/**\n * A {@link TtsEngine} wrapper that truncates, serves repeats from disk and resolves SSML against what the\n * wrapped engine actually supports. Every cache operation is best-effort: a broken or read-only cache\n * directory must never stop the assistant from speaking.\n */\nexport class CachedTts implements TtsEngine {\n private readonly maxChars: number;\n private readonly maxCacheChars: number;\n private readonly maxBytes: number;\n private ready = false;\n\n constructor(\n private readonly engine: TtsEngine,\n private readonly opts: TtsCacheOptions,\n ) {\n this.maxChars = opts.maxChars ?? MAX_TTS_CHARS;\n this.maxCacheChars = opts.maxCacheChars ?? MAX_CACHE_CHARS;\n this.maxBytes = opts.maxBytes ?? DEFAULT_CACHE_BYTES;\n }\n\n prepare(lang: string): Promise<void> {\n return this.engine.prepare ? this.engine.prepare(lang) : Promise.resolve();\n }\n\n async synthesize(text: string, lang: string): Promise<TtsResult> {\n const wanted = this.maxChars > 0 ? truncateForTts(text, this.maxChars) : (text || '').trim();\n if (wanted.length < (text || '').trim().length) {\n this.opts.log.warn(`TTS text shortened from ${text.trim().length} to ${wanted.length} characters.`);\n }\n const cacheable = this.maxCacheChars === 0 || wanted.length <= this.maxCacheChars;\n const file = cacheable ? this.entryPath(wanted, lang) : '';\n if (file) {\n const hit = this.read(file);\n if (hit) {\n this.opts.log.debug(`TTS cache hit: \"${wanted.slice(0, 40)}\"`);\n return hit;\n }\n }\n const result = await this.speak(wanted, lang);\n if (file && result.pcm.length) {\n this.write(file, result);\n }\n return result;\n }\n\n /** Hand SSML to the engine only if it can speak it; otherwise speak the text without the markup. */\n private speak(text: string, lang: string): Promise<TtsResult> {\n if (!isSsml(text)) {\n return this.engine.synthesize(text, lang);\n }\n if (this.engine.synthesizeSsml) {\n return this.engine.synthesizeSsml(text, lang);\n }\n this.opts.log.debug('TTS engine cannot speak SSML — using the plain text.');\n return this.engine.synthesize(stripSsml(text), lang);\n }\n\n /** Is this text already on disk? Used by the warm-up to skip what it has. */\n has(text: string, lang: string): boolean {\n const wanted = this.maxChars > 0 ? truncateForTts(text, this.maxChars) : (text || '').trim();\n const file = this.entryPath(wanted, lang);\n try {\n return fs.existsSync(file);\n } catch {\n return false;\n }\n }\n\n /** Pre-synthesise frequently spoken phrases, so the first \"Ok.\" of the day is not a cloud call. */\n async warm(phrases: string[], lang: string): Promise<number> {\n let added = 0;\n for (const phrase of phrases) {\n const text = (phrase || '').trim();\n if (!text || this.has(text, lang)) {\n continue;\n }\n try {\n await this.synthesize(text, lang);\n added++;\n } catch (e) {\n this.opts.log.debug(`TTS warm-up failed for \"${text.slice(0, 30)}\": ${(e as Error).message}`);\n break; // the engine is unavailable — no point trying the rest\n }\n }\n if (added) {\n this.opts.log.info(`TTS cache warmed with ${added} phrase(s).`);\n }\n return added;\n }\n\n /** The language is part of the key: the same words are spoken differently in another language. */\n private entryPath(text: string, lang: string): string {\n const key = crypto.createHash('sha256').update(`${lang}\\u0000${text}`).digest('hex').slice(0, 20);\n return path.join(this.opts.dir, `${key}.pcm`);\n }\n\n private read(file: string): TtsResult | null {\n try {\n if (!fs.existsSync(file)) {\n return null;\n }\n const entry = decodeEntry(fs.readFileSync(file));\n if (entry) {\n // Touch it, so pruning drops what nobody asks for rather than what is merely old.\n const now = new Date();\n try {\n fs.utimesSync(file, now, now);\n } catch {\n /* a read-only cache is still a usable cache */\n }\n }\n return entry;\n } catch (e) {\n this.opts.log.debug(`TTS cache read failed: ${(e as Error).message}`);\n return null;\n }\n }\n\n private write(file: string, result: TtsResult): void {\n try {\n if (!this.ready) {\n fs.mkdirSync(this.opts.dir, { recursive: true });\n this.ready = true;\n }\n fs.writeFileSync(file, encodeEntry(result));\n this.prune();\n } catch (e) {\n this.opts.log.debug(`TTS cache write failed: ${(e as Error).message}`);\n }\n }\n\n /** Keep the directory under its budget by dropping the least recently used entries. */\n private prune(): void {\n if (this.maxBytes <= 0) {\n return;\n }\n try {\n const entries = fs\n .readdirSync(this.opts.dir)\n .filter(name => name.endsWith('.pcm'))\n .map(name => {\n const full = path.join(this.opts.dir, name);\n const stat = fs.statSync(full);\n return { full, size: stat.size, used: stat.mtimeMs };\n });\n let total = entries.reduce((sum, e) => sum + e.size, 0);\n if (total <= this.maxBytes) {\n return;\n }\n for (const entry of entries.sort((a, b) => a.used - b.used)) {\n if (total <= this.maxBytes) {\n break;\n }\n fs.unlinkSync(entry.full);\n total -= entry.size;\n }\n this.opts.log.debug(`TTS cache pruned to ${Math.round(total / 1024)} KiB.`);\n } catch (e) {\n this.opts.log.debug(`TTS cache prune failed: ${(e as Error).message}`);\n }\n }\n}\n"]}
1
+ {"version":3,"file":"ttsCache.js","sourceRoot":"","sources":["../../../src/lib/voice/ttsCache.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The plumbing every TTS engine benefits from: a disk cache, a length limit, and SSML handling.\n *\n * Wrapped around any {@link TtsEngine}, so each engine stays a thin provider binding:\n * - **cache** — the assistant says the same short things over and over (\"Ok.\", \"Timer abgelaufen\",\n * a trigger's announcement). Synthesising those again on every repeat costs a cloud round-trip and\n * a few hundred milliseconds of latency for audio that is byte-for-byte identical. Only short texts\n * are cached: a long one-off answer will never be asked for twice, so it would only fill the disk.\n * - **length limit** — an LLM can answer with three paragraphs. Spoken at a speaker that is a minute\n * of monologue nobody can interrupt, so the text is cut at the last sentence end that fits.\n * - **SSML** — Azure and Polly can speak `<speak>…</speak>` (pauses, emphasis); OpenAI and the local\n * engines cannot, so there the tags are stripped rather than read out loud.\n *\n * All three come from the Python original (`C:\\iot\\Hannah`, `core/hannah/tts.py`: `_TTSCache`,\n * `_truncate_for_tts`, `_strip_ssml_tags`), including the detail that the sample rate belongs in the\n * cache entry — serving 24 kHz audio as 16 kHz plays it back at the wrong pitch.\n */\nimport * as crypto from 'node:crypto';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport type { TtsEngine, TtsResult } from './tts';\nimport type { VoiceLogger } from './download';\n\n/** Hard limit for a spoken text. Hannah's 400 characters ≈ 25 seconds of speech. */\nexport const MAX_TTS_CHARS = 400;\n\n/**\n * The configured length limit for spoken text: a number ≥ 0 (0 = no limit), or undefined for the default\n * ({@link MAX_TTS_CHARS}) — also for installations from before the setting and for a field the user emptied.\n */\nexport function parseTtsMaxChars(value: unknown): number | undefined {\n if (value === '' || value === null || value === undefined) {\n return undefined;\n }\n const n = Math.round(Number(value));\n return Number.isFinite(n) && n >= 0 ? n : undefined;\n}\n\n/**\n * Instruction for a model whose answer will be spoken and cut at `limit` characters: answer within it instead\n * of being cut mid-thought. Models cannot count characters, so the limit also comes as a number of sentences.\n * Also asks not to read out state ids and serial numbers — over a speaker they are a minute of digits.\n * Empty when there is no limit.\n */\nexport function spokenLengthHint(limit: number, lang: string): string {\n if (!(limit > 0)) {\n return '';\n }\n const n = Math.max(1, Math.round(limit / 120));\n if (lang === 'ru') {\n const sentences =\n n === 1\n ? 'одно короткое предложение'\n : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)\n ? `${n} коротких предложения`\n : `${n} коротких предложений`;\n return (\n `Твой ответ зачитывается вслух и обрезается после ${limit} символов. Поэтому отвечай не длиннее ${limit} ` +\n `символов (примерно ${sentences}), главное — в начале. Не зачитывай ID состояний, серийные номера и ` +\n 'длинные числа, если пользователь сам об этом не просит. Если нужно сказать больше, кратко подведи итог ' +\n 'и предложи рассказать подробнее.'\n );\n }\n if (lang === 'de') {\n const sentences = n === 1 ? 'einen kurzen Satz' : `${n} kurze Sätze`;\n return (\n `Deine Antwort wird vorgelesen und nach ${limit} Zeichen abgeschnitten. Antworte deshalb in höchstens ` +\n `${limit} Zeichen (etwa ${sentences}), das Wichtigste zuerst. Lies keine State-IDs, Seriennummern oder ` +\n 'langen Zahlen vor, außer der Nutzer fragt ausdrücklich danach. Ist mehr zu sagen, fasse zusammen und ' +\n 'biete an, Details zu nennen.'\n );\n }\n const sentences = n === 1 ? 'one short sentence' : `${n} short sentences`;\n return (\n `Your answer is read aloud and cut off after ${limit} characters. Keep it within ${limit} characters ` +\n `(about ${sentences}), most important point first. Do not read out state ids, serial numbers or long ` +\n 'numbers unless the user asks for them. If there is more to say, summarise and offer the details.'\n );\n}\n\n/** Texts up to this length are worth caching; a longer one is a one-off answer. */\nexport const MAX_CACHE_CHARS = 200;\n\n/** Default budget for the cache directory. ~1 MB per 20 s of 24 kHz 16-bit audio. */\nexport const DEFAULT_CACHE_BYTES = 64 * 1024 * 1024;\n\n/** Does this text ask for SSML? */\nexport function isSsml(text: string): boolean {\n return /^\\s*<speak[\\s>]/i.test(text || '');\n}\n\n/** Drop the XML tags, for an engine that cannot speak SSML (reading them aloud would be worse). */\nexport function stripSsml(text: string): string {\n return (text || '')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/**\n * Cut `text` to `maxChars`, preferring the last sentence end in the second half so the result does not\n * stop mid-thought; failing that, the last word boundary plus an ellipsis. SSML is left alone — cutting\n * it would break the markup, and it is written by us, not by a model.\n */\nexport function truncateForTts(text: string, maxChars = MAX_TTS_CHARS): string {\n const t = (text || '').trim();\n if (t.length <= maxChars || isSsml(t)) {\n return t;\n }\n const chunk = t.slice(0, maxChars);\n for (const sep of ['.', '!', '?', '…']) {\n const idx = chunk.lastIndexOf(sep);\n if (idx > maxChars / 2) {\n return chunk.slice(0, idx + 1);\n }\n }\n const lastSpace = chunk.lastIndexOf(' ');\n return `${(lastSpace > 0 ? chunk.slice(0, lastSpace) : chunk).trim()} …`;\n}\n\n/** A cache entry on disk: the sample rate first, so an entry can never be replayed at the wrong pitch. */\nfunction encodeEntry(result: TtsResult): Buffer {\n const head = Buffer.alloc(4);\n head.writeUInt32LE(result.sampleRate, 0);\n return Buffer.concat([head, result.pcm]);\n}\n\nfunction decodeEntry(data: Buffer): TtsResult | null {\n if (data.length < 8) {\n return null;\n }\n const sampleRate = data.readUInt32LE(0);\n // A plausible speech rate; anything else means the file is not one of ours (or is truncated).\n if (sampleRate < 8000 || sampleRate > 48000) {\n return null;\n }\n return { pcm: data.subarray(4), sampleRate };\n}\n\nexport interface TtsCacheOptions {\n /** Directory for this engine's entries (one per provider+voice, so a voice change cannot serve stale audio). */\n dir: string;\n log: VoiceLogger;\n /** Cut texts longer than this before synthesising (0 = no limit). */\n maxChars?: number;\n /** Only cache texts up to this length (0 = cache everything). */\n maxCacheChars?: number;\n /** Prune the oldest entries once the directory exceeds this (0 = no limit). */\n maxBytes?: number;\n}\n\n/**\n * A {@link TtsEngine} wrapper that truncates, serves repeats from disk and resolves SSML against what the\n * wrapped engine actually supports. Every cache operation is best-effort: a broken or read-only cache\n * directory must never stop the assistant from speaking.\n */\nexport class CachedTts implements TtsEngine {\n private readonly maxChars: number;\n private readonly maxCacheChars: number;\n private readonly maxBytes: number;\n private ready = false;\n\n constructor(\n private readonly engine: TtsEngine,\n private readonly opts: TtsCacheOptions,\n ) {\n this.maxChars = opts.maxChars ?? MAX_TTS_CHARS;\n this.maxCacheChars = opts.maxCacheChars ?? MAX_CACHE_CHARS;\n this.maxBytes = opts.maxBytes ?? DEFAULT_CACHE_BYTES;\n }\n\n prepare(lang: string): Promise<void> {\n return this.engine.prepare ? this.engine.prepare(lang) : Promise.resolve();\n }\n\n async synthesize(text: string, lang: string): Promise<TtsResult> {\n const wanted = this.maxChars > 0 ? truncateForTts(text, this.maxChars) : (text || '').trim();\n if (wanted.length < (text || '').trim().length) {\n this.opts.log.warn(\n `TTS text shortened from ${text.trim().length} to ${wanted.length} characters ` +\n `(limit ${this.maxChars}: \"Longest spoken answer\" in the Voice tab, 0 = no limit).`,\n );\n }\n const cacheable = this.maxCacheChars === 0 || wanted.length <= this.maxCacheChars;\n const file = cacheable ? this.entryPath(wanted, lang) : '';\n if (file) {\n const hit = this.read(file);\n if (hit) {\n this.opts.log.debug(`TTS cache hit: \"${wanted.slice(0, 40)}\"`);\n return hit;\n }\n }\n const result = await this.speak(wanted, lang);\n if (file && result.pcm.length) {\n this.write(file, result);\n }\n return result;\n }\n\n /** Hand SSML to the engine only if it can speak it; otherwise speak the text without the markup. */\n private speak(text: string, lang: string): Promise<TtsResult> {\n if (!isSsml(text)) {\n return this.engine.synthesize(text, lang);\n }\n if (this.engine.synthesizeSsml) {\n return this.engine.synthesizeSsml(text, lang);\n }\n this.opts.log.debug('TTS engine cannot speak SSML — using the plain text.');\n return this.engine.synthesize(stripSsml(text), lang);\n }\n\n /** Is this text already on disk? Used by the warm-up to skip what it has. */\n has(text: string, lang: string): boolean {\n const wanted = this.maxChars > 0 ? truncateForTts(text, this.maxChars) : (text || '').trim();\n const file = this.entryPath(wanted, lang);\n try {\n return fs.existsSync(file);\n } catch {\n return false;\n }\n }\n\n /** Pre-synthesise frequently spoken phrases, so the first \"Ok.\" of the day is not a cloud call. */\n async warm(phrases: string[], lang: string): Promise<number> {\n let added = 0;\n for (const phrase of phrases) {\n const text = (phrase || '').trim();\n if (!text || this.has(text, lang)) {\n continue;\n }\n try {\n await this.synthesize(text, lang);\n added++;\n } catch (e) {\n this.opts.log.debug(`TTS warm-up failed for \"${text.slice(0, 30)}\": ${(e as Error).message}`);\n break; // the engine is unavailable — no point trying the rest\n }\n }\n if (added) {\n this.opts.log.info(`TTS cache warmed with ${added} phrase(s).`);\n }\n return added;\n }\n\n /** The language is part of the key: the same words are spoken differently in another language. */\n private entryPath(text: string, lang: string): string {\n const key = crypto.createHash('sha256').update(`${lang}\\u0000${text}`).digest('hex').slice(0, 20);\n return path.join(this.opts.dir, `${key}.pcm`);\n }\n\n private read(file: string): TtsResult | null {\n try {\n if (!fs.existsSync(file)) {\n return null;\n }\n const entry = decodeEntry(fs.readFileSync(file));\n if (entry) {\n // Touch it, so pruning drops what nobody asks for rather than what is merely old.\n const now = new Date();\n try {\n fs.utimesSync(file, now, now);\n } catch {\n /* a read-only cache is still a usable cache */\n }\n }\n return entry;\n } catch (e) {\n this.opts.log.debug(`TTS cache read failed: ${(e as Error).message}`);\n return null;\n }\n }\n\n private write(file: string, result: TtsResult): void {\n try {\n if (!this.ready) {\n fs.mkdirSync(this.opts.dir, { recursive: true });\n this.ready = true;\n }\n fs.writeFileSync(file, encodeEntry(result));\n this.prune();\n } catch (e) {\n this.opts.log.debug(`TTS cache write failed: ${(e as Error).message}`);\n }\n }\n\n /** Keep the directory under its budget by dropping the least recently used entries. */\n private prune(): void {\n if (this.maxBytes <= 0) {\n return;\n }\n try {\n const entries = fs\n .readdirSync(this.opts.dir)\n .filter(name => name.endsWith('.pcm'))\n .map(name => {\n const full = path.join(this.opts.dir, name);\n const stat = fs.statSync(full);\n return { full, size: stat.size, used: stat.mtimeMs };\n });\n let total = entries.reduce((sum, e) => sum + e.size, 0);\n if (total <= this.maxBytes) {\n return;\n }\n for (const entry of entries.sort((a, b) => a.used - b.used)) {\n if (total <= this.maxBytes) {\n break;\n }\n fs.unlinkSync(entry.full);\n total -= entry.size;\n }\n this.opts.log.debug(`TTS cache pruned to ${Math.round(total / 1024)} KiB.`);\n } catch (e) {\n this.opts.log.debug(`TTS cache prune failed: ${(e as Error).message}`);\n }\n }\n}\n"]}
@@ -55,6 +55,7 @@ class SpeechDetector {
55
55
  fixedEnd;
56
56
  silenceMs;
57
57
  maxMs;
58
+ startTimeoutMs;
58
59
  skipMs;
59
60
  adaptive;
60
61
  minStartLevel;
@@ -73,13 +74,19 @@ class SpeechDetector {
73
74
  totalMs = 0;
74
75
  /** Loudest frame seen so far (exposed for logging — the usual clue when a mic is mis-configured). */
75
76
  peak = 0;
76
- constructor({ sampleRate = 16000, frameMs = 30, startLevel = 700, endLevel = 400, silenceMs = 900, maxMs = 12000, skipMs = 0, adaptive = false, minStartLevel = 250, minEndLevel = 150, startFactor = 3.5, endFactor = 2, warmupMs = 150, } = {}) {
77
+ /**
78
+ * Loudest frame after the skipped lead-in, i.e. without the wake chirp. Compared with {@link startLevel}
79
+ * it tells whether a dropped utterance was silence or a voice that was simply too quiet for the gate.
80
+ */
81
+ analysedPeak = 0;
82
+ constructor({ sampleRate = 16000, frameMs = 30, startLevel = 700, endLevel = 400, silenceMs = 900, maxMs = 12000, startTimeoutMs = 0, skipMs = 0, adaptive = false, minStartLevel = 250, minEndLevel = 150, startFactor = 3.5, endFactor = 2, warmupMs = 150, } = {}) {
77
83
  this.frameBytes = Math.round((sampleRate * frameMs) / 1000) * 2;
78
84
  this.frameMs = frameMs;
79
85
  this.fixedStart = startLevel;
80
86
  this.fixedEnd = endLevel;
81
87
  this.silenceMs = silenceMs;
82
88
  this.maxMs = maxMs;
89
+ this.startTimeoutMs = startTimeoutMs;
83
90
  this.skipMs = skipMs;
84
91
  this.adaptive = adaptive;
85
92
  this.minStartLevel = minStartLevel;
@@ -125,6 +132,9 @@ class SpeechDetector {
125
132
  continue;
126
133
  }
127
134
  this.totalMs += this.frameMs;
135
+ if (level > this.analysedPeak) {
136
+ this.analysedPeak = level;
137
+ }
128
138
  if (this.adaptive) {
129
139
  // Three rules, each there to defeat a specific way of getting this wrong:
130
140
  // - start at zero, i.e. maximally sensitive. Seeding from the first frame instead
@@ -162,6 +172,9 @@ class SpeechDetector {
162
172
  else {
163
173
  this.silentMs = 0;
164
174
  }
175
+ if (!this.speechSeen && this.startTimeoutMs > 0 && this.elapsedMs >= this.startTimeoutMs) {
176
+ verdict ||= 'no-speech';
177
+ }
165
178
  if (this.totalMs >= this.maxMs) {
166
179
  verdict ||= 'timeout';
167
180
  }
@@ -1 +1 @@
1
- {"version":3,"file":"vad.js","sourceRoot":"","sources":["../../../src/lib/voice/vad.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * End-of-speech detection for satellites that stream microphone audio until they are told to stop.\n *\n * The Hannah/UDP protocol has the satellite decide when an utterance ended (`audio_end`), and Wyoming\n * clients send `audio-stop` themselves. ESPHome voice satellites do neither: they keep streaming until\n * the server sends `STT_VAD_END`/`STT_END`, so the decision has to happen here.\n *\n * This is a plain energy gate, not a neural VAD — good enough to close an utterance, and it costs\n * nothing per frame. Speech must first rise above `startLevel`; after that, `silenceMs` worth of frames\n * below `endLevel` ends it. `maxMs` is the hard stop for rooms that are simply loud.\n *\n * Fixed thresholds only work when the microphone level is known, and on a real satellite it is not.\n * Measured on a ThirdReality Voice & Music Assistant: the device plays a wake-acknowledgement chirp\n * into its own microphone that peaks at full scale (RMS ~16000, clipped), while the speech that follows\n * arrives at RMS 358–469 — below the 700 default for `startLevel` and right on the 400 for `endLevel`.\n * That breaks the gate twice over: `sawSpeech` is satisfied by the chirp rather than by any voice, so\n * the \"nothing but speech-free audio\" guard never fires, and half the real speech frames count as\n * silence, so the utterance can be cut off mid-sentence.\n *\n * Hence two opt-in knobs, both enabled by the ESPHome transport:\n *\n * - `skipMs` drops the leading chirp from the *analysis* (never from the audio, which still goes to STT\n * in full — a fast speaker may already be talking).\n * - `adaptive` derives the thresholds from a tracked noise floor instead of fixed levels. The floor\n * follows the quietest frame seen (instant down, very slow up), so it settles on room noise even when\n * someone starts talking immediately — which a mean or median over a calibration window would not.\n */\n\n/**\n * A buffer that may be a view onto any `ArrayBufferLike` — what `Buffer.subarray()` and\n * `Buffer.concat()` hand back. Plain `Buffer` (= `Buffer<ArrayBuffer>`) assigns to it, not the reverse.\n */\nexport type Bytes = Buffer<ArrayBufferLike>;\n\n/** Root-mean-square amplitude (0…32767) of a mono 16-bit signed LE PCM buffer. */\nexport function rms(pcm: Bytes): number {\n const samples = Math.floor(pcm.length / 2);\n if (!samples) {\n return 0;\n }\n let sum = 0;\n for (let i = 0; i < samples; i++) {\n const value = pcm.readInt16LE(i * 2);\n sum += value * value;\n }\n return Math.sqrt(sum / samples);\n}\n\nexport interface SpeechDetectorOptions {\n /** Sample rate of the incoming PCM (ESPHome satellites always capture at 16 kHz). */\n sampleRate?: number;\n /** Analysis window; 30 ms is the usual compromise between latency and stability. */\n frameMs?: number;\n /** RMS above which we consider speech to have started. Ignored when `adaptive` is on. */\n startLevel?: number;\n /** RMS below which a frame counts as silence. Ignored when `adaptive` is on. */\n endLevel?: number;\n /** Silence after speech that ends the utterance. */\n silenceMs?: number;\n /** Hard cap on one utterance, in case the room never falls quiet. */\n maxMs?: number;\n /**\n * Leading audio to leave out of the analysis — for devices that chirp into their own microphone\n * when the wake word fires. The audio itself is untouched; this only stops the chirp from being\n * mistaken for speech and from poisoning the noise floor.\n */\n skipMs?: number;\n /** Derive the thresholds from the tracked noise floor instead of using fixed levels. */\n adaptive?: boolean;\n /** Adaptive mode: `startLevel` is at least this, however quiet the room is. */\n minStartLevel?: number;\n /** Adaptive mode: `endLevel` is at least this. */\n minEndLevel?: number;\n /** Adaptive mode: speech threshold as a multiple of the noise floor. */\n startFactor?: number;\n /** Adaptive mode: silence threshold as a multiple of the noise floor. */\n endFactor?: number;\n /** Adaptive mode: settle the noise floor this long before speech may be declared. */\n warmupMs?: number;\n}\n\n/** Why an utterance ended: the speaker stopped, or we hit the hard cap. */\nexport type SpeechEnd = 'speech-end' | 'timeout';\n\n/**\n * How fast the tracked noise floor may follow a *rising* ambient level, per analysed frame (30 ms).\n * Fast enough that a genuinely noisy room is matched within about a second, slow enough that a voice\n * cannot pull the floor up to its own level before it has been recognised as speech.\n */\nconst NOISE_RISE = 0.02;\n\nexport class SpeechDetector {\n private readonly frameBytes: number;\n private readonly frameMs: number;\n private readonly fixedStart: number;\n private readonly fixedEnd: number;\n private readonly silenceMs: number;\n private readonly maxMs: number;\n private readonly skipMs: number;\n private readonly adaptive: boolean;\n private readonly minStartLevel: number;\n private readonly minEndLevel: number;\n private readonly startFactor: number;\n private readonly endFactor: number;\n private readonly warmupMs: number;\n\n private pending: Bytes = Buffer.alloc(0);\n private speechSeen = false;\n private silentMs = 0;\n /** Tracked room level in adaptive mode; null until the first analysed frame. */\n private floor: number | null = null;\n /** Audio seen so far *including* the skipped lead-in, in milliseconds. */\n private elapsedMs = 0;\n /** Audio analysed so far, in milliseconds (exposed for logging). */\n totalMs = 0;\n /** Loudest frame seen so far (exposed for logging — the usual clue when a mic is mis-configured). */\n peak = 0;\n\n constructor({\n sampleRate = 16000,\n frameMs = 30,\n startLevel = 700,\n endLevel = 400,\n silenceMs = 900,\n maxMs = 12000,\n skipMs = 0,\n adaptive = false,\n minStartLevel = 250,\n minEndLevel = 150,\n startFactor = 3.5,\n endFactor = 2,\n warmupMs = 150,\n }: SpeechDetectorOptions = {}) {\n this.frameBytes = Math.round((sampleRate * frameMs) / 1000) * 2;\n this.frameMs = frameMs;\n this.fixedStart = startLevel;\n this.fixedEnd = endLevel;\n this.silenceMs = silenceMs;\n this.maxMs = maxMs;\n this.skipMs = skipMs;\n this.adaptive = adaptive;\n this.minStartLevel = minStartLevel;\n this.minEndLevel = minEndLevel;\n this.startFactor = startFactor;\n this.endFactor = endFactor;\n this.warmupMs = warmupMs;\n }\n\n /** True once any frame crossed {@link startLevel} — an utterance that never did is room noise. */\n get sawSpeech(): boolean {\n return this.speechSeen;\n }\n\n /** Tracked room level (adaptive mode), for logging. 0 before the first analysed frame. */\n get noiseFloor(): number {\n return this.floor ?? 0;\n }\n\n /** Current speech threshold — fixed, or derived from the noise floor in adaptive mode. */\n get startLevel(): number {\n return this.adaptive ? Math.max(this.minStartLevel, (this.floor ?? 0) * this.startFactor) : this.fixedStart;\n }\n\n /** Current silence threshold. Never above {@link startLevel}, so the gate keeps its hysteresis. */\n get endLevel(): number {\n if (!this.adaptive) {\n return this.fixedEnd;\n }\n return Math.min(this.startLevel, Math.max(this.minEndLevel, (this.floor ?? 0) * this.endFactor));\n }\n\n /** Feed one audio chunk (any size). Returns why the utterance ended, or null to keep listening. */\n push(chunk: Bytes): SpeechEnd | null {\n this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;\n let verdict: SpeechEnd | null = null;\n\n while (this.pending.length >= this.frameBytes) {\n const frame = this.pending.subarray(0, this.frameBytes);\n this.pending = this.pending.subarray(this.frameBytes);\n this.elapsedMs += this.frameMs;\n\n const level = rms(frame);\n if (level > this.peak) {\n this.peak = level;\n }\n\n // The device's own wake chirp: loud, clipped, and not speech. Leaving it out of the\n // analysis is what makes `sawSpeech` mean anything, and keeps the noise floor honest.\n if (this.elapsedMs <= this.skipMs) {\n continue;\n }\n this.totalMs += this.frameMs;\n\n if (this.adaptive) {\n // Three rules, each there to defeat a specific way of getting this wrong:\n // - start at zero, i.e. maximally sensitive. Seeding from the first frame instead\n // lets someone who talks straight after the wake word install their own voice as\n // the room's noise floor, which shuts the gate for good — the same trap an average\n // or median over a calibration window falls into.\n // - fall instantly, so a room that went quiet is believed at once.\n // - rise slowly, and not at all once speech has been recognised: otherwise a long\n // utterance drags the floor, and with it `endLevel`, up to its own level and cuts\n // itself off mid-sentence. Falling stays on, so the pauses between words still count.\n this.floor =\n this.floor === null\n ? 0\n : level < this.floor\n ? level\n : this.speechSeen\n ? this.floor\n : this.floor + (level - this.floor) * NOISE_RISE;\n if (this.totalMs < this.warmupMs) {\n continue; // let the floor settle before anything may count as speech\n }\n }\n\n if (!this.speechSeen) {\n if (level >= this.startLevel) {\n this.speechSeen = true;\n this.silentMs = 0;\n }\n } else if (level < this.endLevel) {\n this.silentMs += this.frameMs;\n if (this.silentMs >= this.silenceMs) {\n verdict ||= 'speech-end';\n }\n } else {\n this.silentMs = 0;\n }\n\n if (this.totalMs >= this.maxMs) {\n verdict ||= 'timeout';\n }\n }\n return verdict;\n }\n}\n"]}
1
+ {"version":3,"file":"vad.js","sourceRoot":"","sources":["../../../src/lib/voice/vad.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * End-of-speech detection for satellites that stream microphone audio until they are told to stop.\n *\n * The Hannah/UDP protocol has the satellite decide when an utterance ended (`audio_end`), and Wyoming\n * clients send `audio-stop` themselves. ESPHome voice satellites do neither: they keep streaming until\n * the server sends `STT_VAD_END`/`STT_END`, so the decision has to happen here.\n *\n * This is a plain energy gate, not a neural VAD — good enough to close an utterance, and it costs\n * nothing per frame. Speech must first rise above `startLevel`; after that, `silenceMs` worth of frames\n * below `endLevel` ends it. `maxMs` is the hard stop for rooms that are simply loud.\n *\n * Fixed thresholds only work when the microphone level is known, and on a real satellite it is not.\n * Measured on a ThirdReality Voice & Music Assistant: the device plays a wake-acknowledgement chirp\n * into its own microphone that peaks at full scale (RMS ~16000, clipped), while the speech that follows\n * arrives at RMS 358–469 — below the 700 default for `startLevel` and right on the 400 for `endLevel`.\n * That breaks the gate twice over: `sawSpeech` is satisfied by the chirp rather than by any voice, so\n * the \"nothing but speech-free audio\" guard never fires, and half the real speech frames count as\n * silence, so the utterance can be cut off mid-sentence.\n *\n * Hence two opt-in knobs, both enabled by the ESPHome transport:\n *\n * - `skipMs` drops the leading chirp from the *analysis* (never from the audio, which still goes to STT\n * in full — a fast speaker may already be talking).\n * - `adaptive` derives the thresholds from a tracked noise floor instead of fixed levels. The floor\n * follows the quietest frame seen (instant down, very slow up), so it settles on room noise even when\n * someone starts talking immediately — which a mean or median over a calibration window would not.\n */\n\n/**\n * A buffer that may be a view onto any `ArrayBufferLike` — what `Buffer.subarray()` and\n * `Buffer.concat()` hand back. Plain `Buffer` (= `Buffer<ArrayBuffer>`) assigns to it, not the reverse.\n */\nexport type Bytes = Buffer<ArrayBufferLike>;\n\n/** Root-mean-square amplitude (0…32767) of a mono 16-bit signed LE PCM buffer. */\nexport function rms(pcm: Bytes): number {\n const samples = Math.floor(pcm.length / 2);\n if (!samples) {\n return 0;\n }\n let sum = 0;\n for (let i = 0; i < samples; i++) {\n const value = pcm.readInt16LE(i * 2);\n sum += value * value;\n }\n return Math.sqrt(sum / samples);\n}\n\nexport interface SpeechDetectorOptions {\n /** Sample rate of the incoming PCM (ESPHome satellites always capture at 16 kHz). */\n sampleRate?: number;\n /** Analysis window; 30 ms is the usual compromise between latency and stability. */\n frameMs?: number;\n /** RMS above which we consider speech to have started. Ignored when `adaptive` is on. */\n startLevel?: number;\n /** RMS below which a frame counts as silence. Ignored when `adaptive` is on. */\n endLevel?: number;\n /** Silence after speech that ends the utterance. */\n silenceMs?: number;\n /** Hard cap on one utterance, in case the room never falls quiet. */\n maxMs?: number;\n /**\n * Give up when no speech has started this long after the stream began (the skipped lead-in included);\n * 0 = only {@link maxMs}. A false wake would otherwise keep the microphone open for the whole `maxMs`.\n */\n startTimeoutMs?: number;\n /**\n * Leading audio to leave out of the analysis — for devices that chirp into their own microphone\n * when the wake word fires. The audio itself is untouched; this only stops the chirp from being\n * mistaken for speech and from poisoning the noise floor.\n */\n skipMs?: number;\n /** Derive the thresholds from the tracked noise floor instead of using fixed levels. */\n adaptive?: boolean;\n /** Adaptive mode: `startLevel` is at least this, however quiet the room is. */\n minStartLevel?: number;\n /** Adaptive mode: `endLevel` is at least this. */\n minEndLevel?: number;\n /** Adaptive mode: speech threshold as a multiple of the noise floor. */\n startFactor?: number;\n /** Adaptive mode: silence threshold as a multiple of the noise floor. */\n endFactor?: number;\n /** Adaptive mode: settle the noise floor this long before speech may be declared. */\n warmupMs?: number;\n}\n\n/** Why an utterance ended: the speaker stopped, we hit the hard cap, or nobody started speaking at all. */\nexport type SpeechEnd = 'speech-end' | 'timeout' | 'no-speech';\n\n/**\n * How fast the tracked noise floor may follow a *rising* ambient level, per analysed frame (30 ms).\n * Fast enough that a genuinely noisy room is matched within about a second, slow enough that a voice\n * cannot pull the floor up to its own level before it has been recognised as speech.\n */\nconst NOISE_RISE = 0.02;\n\nexport class SpeechDetector {\n private readonly frameBytes: number;\n private readonly frameMs: number;\n private readonly fixedStart: number;\n private readonly fixedEnd: number;\n private readonly silenceMs: number;\n private readonly maxMs: number;\n private readonly startTimeoutMs: number;\n private readonly skipMs: number;\n private readonly adaptive: boolean;\n private readonly minStartLevel: number;\n private readonly minEndLevel: number;\n private readonly startFactor: number;\n private readonly endFactor: number;\n private readonly warmupMs: number;\n\n private pending: Bytes = Buffer.alloc(0);\n private speechSeen = false;\n private silentMs = 0;\n /** Tracked room level in adaptive mode; null until the first analysed frame. */\n private floor: number | null = null;\n /** Audio seen so far *including* the skipped lead-in, in milliseconds. */\n private elapsedMs = 0;\n /** Audio analysed so far, in milliseconds (exposed for logging). */\n totalMs = 0;\n /** Loudest frame seen so far (exposed for logging — the usual clue when a mic is mis-configured). */\n peak = 0;\n /**\n * Loudest frame after the skipped lead-in, i.e. without the wake chirp. Compared with {@link startLevel}\n * it tells whether a dropped utterance was silence or a voice that was simply too quiet for the gate.\n */\n analysedPeak = 0;\n\n constructor({\n sampleRate = 16000,\n frameMs = 30,\n startLevel = 700,\n endLevel = 400,\n silenceMs = 900,\n maxMs = 12000,\n startTimeoutMs = 0,\n skipMs = 0,\n adaptive = false,\n minStartLevel = 250,\n minEndLevel = 150,\n startFactor = 3.5,\n endFactor = 2,\n warmupMs = 150,\n }: SpeechDetectorOptions = {}) {\n this.frameBytes = Math.round((sampleRate * frameMs) / 1000) * 2;\n this.frameMs = frameMs;\n this.fixedStart = startLevel;\n this.fixedEnd = endLevel;\n this.silenceMs = silenceMs;\n this.maxMs = maxMs;\n this.startTimeoutMs = startTimeoutMs;\n this.skipMs = skipMs;\n this.adaptive = adaptive;\n this.minStartLevel = minStartLevel;\n this.minEndLevel = minEndLevel;\n this.startFactor = startFactor;\n this.endFactor = endFactor;\n this.warmupMs = warmupMs;\n }\n\n /** True once any frame crossed {@link startLevel} — an utterance that never did is room noise. */\n get sawSpeech(): boolean {\n return this.speechSeen;\n }\n\n /** Tracked room level (adaptive mode), for logging. 0 before the first analysed frame. */\n get noiseFloor(): number {\n return this.floor ?? 0;\n }\n\n /** Current speech threshold — fixed, or derived from the noise floor in adaptive mode. */\n get startLevel(): number {\n return this.adaptive ? Math.max(this.minStartLevel, (this.floor ?? 0) * this.startFactor) : this.fixedStart;\n }\n\n /** Current silence threshold. Never above {@link startLevel}, so the gate keeps its hysteresis. */\n get endLevel(): number {\n if (!this.adaptive) {\n return this.fixedEnd;\n }\n return Math.min(this.startLevel, Math.max(this.minEndLevel, (this.floor ?? 0) * this.endFactor));\n }\n\n /** Feed one audio chunk (any size). Returns why the utterance ended, or null to keep listening. */\n push(chunk: Bytes): SpeechEnd | null {\n this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;\n let verdict: SpeechEnd | null = null;\n\n while (this.pending.length >= this.frameBytes) {\n const frame = this.pending.subarray(0, this.frameBytes);\n this.pending = this.pending.subarray(this.frameBytes);\n this.elapsedMs += this.frameMs;\n\n const level = rms(frame);\n if (level > this.peak) {\n this.peak = level;\n }\n\n // The device's own wake chirp: loud, clipped, and not speech. Leaving it out of the\n // analysis is what makes `sawSpeech` mean anything, and keeps the noise floor honest.\n if (this.elapsedMs <= this.skipMs) {\n continue;\n }\n this.totalMs += this.frameMs;\n if (level > this.analysedPeak) {\n this.analysedPeak = level;\n }\n\n if (this.adaptive) {\n // Three rules, each there to defeat a specific way of getting this wrong:\n // - start at zero, i.e. maximally sensitive. Seeding from the first frame instead\n // lets someone who talks straight after the wake word install their own voice as\n // the room's noise floor, which shuts the gate for good — the same trap an average\n // or median over a calibration window falls into.\n // - fall instantly, so a room that went quiet is believed at once.\n // - rise slowly, and not at all once speech has been recognised: otherwise a long\n // utterance drags the floor, and with it `endLevel`, up to its own level and cuts\n // itself off mid-sentence. Falling stays on, so the pauses between words still count.\n this.floor =\n this.floor === null\n ? 0\n : level < this.floor\n ? level\n : this.speechSeen\n ? this.floor\n : this.floor + (level - this.floor) * NOISE_RISE;\n if (this.totalMs < this.warmupMs) {\n continue; // let the floor settle before anything may count as speech\n }\n }\n\n if (!this.speechSeen) {\n if (level >= this.startLevel) {\n this.speechSeen = true;\n this.silentMs = 0;\n }\n } else if (level < this.endLevel) {\n this.silentMs += this.frameMs;\n if (this.silentMs >= this.silenceMs) {\n verdict ||= 'speech-end';\n }\n } else {\n this.silentMs = 0;\n }\n\n if (!this.speechSeen && this.startTimeoutMs > 0 && this.elapsedMs >= this.startTimeoutMs) {\n verdict ||= 'no-speech';\n }\n if (this.totalMs >= this.maxMs) {\n verdict ||= 'timeout';\n }\n }\n return verdict;\n }\n}\n"]}
package/build/main.js CHANGED
@@ -67,6 +67,8 @@ const wakeWords_1 = require("./lib/voice/wakeWords");
67
67
  const tone_1 = require("./lib/voice/tone");
68
68
  const engines_1 = require("./lib/voice/engines");
69
69
  const openrouter_1 = require("./lib/voice/openrouter");
70
+ const ttsCache_1 = require("./lib/voice/ttsCache");
71
+ const captures_1 = require("./lib/voice/captures");
70
72
  const piAgent_1 = require("./lib/piAgent");
71
73
  const systemAi_1 = require("./lib/systemAi");
72
74
  /**
@@ -99,6 +101,8 @@ const LOCAL_LLM_MAX_TOKENS = 256;
99
101
  * the TTS cache at startup; everything else is cached on first use.
100
102
  */
101
103
  /** Fields of the Voice tab the voice test takes from the unsaved form (everything an STT or TTS engine is built from). */
104
+ /** Transports whose answer is spoken by a satellite (and therefore cut at `ttsMaxChars`). */
105
+ const VOICE_TRANSPORTS = new Set(['esphome', 'udp', 'native', 'wyoming']);
102
106
  const VOICE_FORM_FIELDS = [
103
107
  'voiceLanguage',
104
108
  'sttProvider',
@@ -650,6 +654,7 @@ class Assistant extends adapter_core_1.Adapter {
650
654
  this.updateSatelliteControl(device, room, entity).catch(e => this.log.debug(`satellite control update failed: ${e.message}`));
651
655
  },
652
656
  expectsFollowUp: (answer, device) => this.followUpFor(device, answer),
657
+ onDiscarded: cfg.esphomeSaveCaptures ? capture => this.keepCapture(capture) : undefined,
653
658
  externalWakeWords: () => [...this.customWakeWords.values()].map(w => ({
654
659
  id: w.id,
655
660
  phrase: w.phrase,
@@ -686,6 +691,7 @@ class Assistant extends adapter_core_1.Adapter {
686
691
  openrouterSttModel: (cfg.openrouterSttModel || '').trim(),
687
692
  openrouterTtsModel: (cfg.openrouterTtsModel || '').trim(),
688
693
  decodeAudio: audio => this.decodeAudioBufferToPcm(audio),
694
+ ttsMaxChars: (0, ttsCache_1.parseTtsMaxChars)(cfg.ttsMaxChars),
689
695
  };
690
696
  }
691
697
  /**
@@ -1120,6 +1126,32 @@ class Assistant extends adapter_core_1.Adapter {
1120
1126
  ack: true,
1121
1127
  });
1122
1128
  }
1129
+ /** Store for `esphomeSaveCaptures`, in the adapter's file storage (Admin → Files → assistant.N/captures). */
1130
+ captureStore = null;
1131
+ /** Keep a dropped ESPHome recording for diagnosis (`esphomeSaveCaptures`). Never blocks the voice pipeline. */
1132
+ keepCapture(capture) {
1133
+ this.captureStore ||= new captures_1.CaptureStore({
1134
+ write: async (path, data) => {
1135
+ await this.writeFileAsync(this.namespace, path, data);
1136
+ },
1137
+ list: async (dir) => {
1138
+ try {
1139
+ const entries = await this.readDirAsync(this.namespace, dir);
1140
+ return entries.filter(e => !e.isDir).map(e => e.file);
1141
+ }
1142
+ catch {
1143
+ return []; // nothing kept yet
1144
+ }
1145
+ },
1146
+ remove: async (path) => {
1147
+ await this.delFileAsync(this.namespace, path);
1148
+ },
1149
+ });
1150
+ this.captureStore
1151
+ .save(capture)
1152
+ .then(path => this.log.debug(`ESPHome ${capture.device}: recording kept as ${this.namespace}/${path}`))
1153
+ .catch(e => this.log.warn(`Could not keep the recording of ${capture.device}: ${e.message}`));
1154
+ }
1123
1155
  /** Read the uploaded wake words (`<instance>/wakewords/<id>.json` + `.tflite`) once. */
1124
1156
  loadCustomWakeWords() {
1125
1157
  this.customWakeWordsLoaded ||= (async () => {
@@ -2675,7 +2707,8 @@ class Assistant extends adapter_core_1.Adapter {
2675
2707
  * later turns have the full thread.
2676
2708
  */
2677
2709
  answer(question, source = '', origin = {}) {
2678
- return this.requestOrigin.run({ source, room: origin.room || '' }, () => this.journal
2710
+ const voice = VOICE_TRANSPORTS.has(origin.transport || '');
2711
+ return this.requestOrigin.run({ source, room: origin.room || '', voice }, () => this.journal
2679
2712
  ? this.journal.run(question, source, origin, () => {
2680
2713
  this.traceSpeechInput(question, origin);
2681
2714
  return this.answerTracked(question, source);
@@ -2775,6 +2808,18 @@ class Assistant extends adapter_core_1.Adapter {
2775
2808
  }
2776
2809
  return 'If the request was only a command to switch devices on or off or to set them to a value, and everything worked, answer with the control marker [[DONE]] alone (a short beep plays instead of a sentence). For questions, errors or when you have something to tell, answer normally.';
2777
2810
  }
2811
+ /**
2812
+ * Length instruction for a request that came in by voice: the answer is cut at `ttsMaxChars` before it is
2813
+ * spoken, so the model is asked to stay within it. Empty for text requests (chat, Telegram, scripts) and
2814
+ * when the limit is off.
2815
+ */
2816
+ spokenLengthHint() {
2817
+ if (!this.config.voiceEnabled || !this.requestOrigin.getStore()?.voice) {
2818
+ return '';
2819
+ }
2820
+ const limit = (0, ttsCache_1.parseTtsMaxChars)(this.config.ttsMaxChars) ?? ttsCache_1.MAX_TTS_CHARS;
2821
+ return (0, ttsCache_1.spokenLengthHint)(limit, String(this.config.voiceLanguage || this.language || 'en'));
2822
+ }
2778
2823
  /** What [[DONE]] says where no beep can be played */
2779
2824
  doneText() {
2780
2825
  const lang = String(this.config.voiceLanguage || this.language || 'en');
@@ -3061,9 +3106,12 @@ class Assistant extends adapter_core_1.Adapter {
3061
3106
  const sys = parts.length ? parts.join('\n\n') : undefined;
3062
3107
  this.log.info(`Cloud LLM [${this.agent.model || 'auto'}] (source='${source}', context=${history.length} turn(s)${mem ? ', memory' : ''}${weather ? ', weather' : ''}${this.presence?.configured ? ', presence' : ''}) — running tool loop…`);
3063
3108
  // Prepend the current date/time (and the weather line, if a source is configured) to the user turn —
3064
- // cache-safe, see buildTimeContext. The stored history keeps the untouched question.
3109
+ // cache-safe, see buildTimeContext. The stored history keeps the untouched question. A spoken request
3110
+ // also gets the length limit, after the question: in the system prompt it would bust the prompt cache
3111
+ // every time voice and text requests alternate.
3065
3112
  const time = this.buildTimeContext();
3066
- const questionForLlm = [time, weather, question].filter(Boolean).join('\n\n');
3113
+ const lengthHint = this.spokenLengthHint();
3114
+ const questionForLlm = [time, weather, question, lengthHint].filter(Boolean).join('\n\n');
3067
3115
  this.journal?.note({
3068
3116
  tier: 'cloud',
3069
3117
  detail: `${this.llm?.provider || this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
@@ -3073,11 +3121,12 @@ class Assistant extends adapter_core_1.Adapter {
3073
3121
  const deviceLines = ctx ? ctx.split('\n').length - 1 : 0;
3074
3122
  this.journal?.step({
3075
3123
  stage: 'context',
3076
- title: `Prompt: ${deviceLines} devices${mem ? ', memory' : ''}${weather ? ', weather' : ''}${followUp ? ', follow-up marker' : ''}`,
3124
+ title: `Prompt: ${deviceLines} devices${mem ? ', memory' : ''}${weather ? ', weather' : ''}${followUp ? ', follow-up marker' : ''}${lengthHint ? ', length limit' : ''}`,
3077
3125
  data: {
3078
3126
  systemPrompt: this.config.systemPrompt || '',
3079
3127
  followUpHint: followUp || undefined,
3080
3128
  doneHint: done || undefined,
3129
+ lengthHint: lengthHint || undefined,
3081
3130
  memory: mem || undefined,
3082
3131
  devices: deviceLines,
3083
3132
  time,
@@ -3114,7 +3163,7 @@ class Assistant extends adapter_core_1.Adapter {
3114
3163
  const followUp = this.config.voiceEnabled ? this.followUpHint() : '';
3115
3164
  // The device list maps names to state ids, so pi-agent can act without searching first. It is the
3116
3165
  // first to go when the instructions get too long — pi-agent can still search.
3117
- const { text: instructions, dropped } = (0, piAgent_1.composeInstructions)([this.config.systemPrompt || '', followUp, this.doneHint()], [this.buildMemoryContext(), await this.buildDeviceContext()]);
3166
+ const { text: instructions, dropped } = (0, piAgent_1.composeInstructions)([this.config.systemPrompt || '', followUp, this.doneHint(), this.spokenLengthHint()], [this.buildMemoryContext(), await this.buildDeviceContext()]);
3118
3167
  if (dropped) {
3119
3168
  this.log.debug(`pi-agent: ${dropped} context block(s) left out, the instructions would be too long.`);
3120
3169
  }