iobroker.assistant 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/admin/custom/.vite/manifest.json +62 -62
- package/admin/custom/assets/Components-CCl7INIF.js +2 -0
- 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
- 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
- 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
- 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
- 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
- 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
- package/admin/custom/assets/hostInit-DpzPHTOk.js +1 -0
- package/admin/custom/assets/{lib-DBbNtolG.js → lib-70DV4zKj.js} +1 -1
- package/admin/custom/assets/{mf-entry-bootstrap-0-7abd30d3.js → mf-entry-bootstrap-0-0119ee25.js} +2 -2
- package/admin/custom/assets/{pendingShares-U0D1MYK4.js → pendingShares-CKvyybtG.js} +1 -1
- package/admin/custom/customComponents.js +2 -2
- package/admin/custom/index.html +2 -2
- package/admin/custom/mf-manifest.json +1 -1
- package/admin/custom/mf-stats.json +1 -1
- package/admin/i18n/de.json +6 -1
- package/admin/i18n/en.json +6 -1
- package/admin/i18n/es.json +6 -1
- package/admin/i18n/fr.json +6 -1
- package/admin/i18n/it.json +6 -1
- package/admin/i18n/nl.json +6 -1
- package/admin/i18n/pl.json +6 -1
- package/admin/i18n/pt.json +6 -1
- package/admin/i18n/ru.json +6 -1
- package/admin/i18n/uk.json +6 -1
- package/admin/i18n/zh-cn.json +6 -1
- package/admin/jsonConfig.json +38 -4
- package/build/lib/voice/captures.js +87 -0
- package/build/lib/voice/captures.js.map +1 -0
- package/build/lib/voice/engines.js +1 -1
- package/build/lib/voice/engines.js.map +1 -1
- package/build/lib/voice/esphome.js +78 -5
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/esphomeEntities.js +19 -5
- package/build/lib/voice/esphomeEntities.js.map +1 -1
- package/build/lib/voice/ttsCache.js +49 -1
- package/build/lib/voice/ttsCache.js.map +1 -1
- package/build/lib/voice/vad.js +14 -1
- package/build/lib/voice/vad.js.map +1 -1
- package/build/main.js +80 -6
- package/build/main.js.map +1 -1
- package/io-package.json +29 -27
- package/package.json +5 -5
- package/admin/custom/assets/Components-D77NmS_I.js +0 -2
- 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
- package/admin/custom/assets/hostInit-Blh7a-Qe.js +0 -1
- /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":"esphomeEntities.js","sourceRoot":"","sources":["../../../src/lib/voice/esphomeEntities.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The ESPHome entity layer: everything a voice satellite exposes *besides* the voice pipeline.\n *\n * A device announces its entities once, in response to `ListEntitiesRequest`, and then pushes a state\n * message whenever one changes. Each entity kind has its own pair of messages plus a command message to\n * write it back — `SwitchCommandRequest`, `NumberCommandRequest` and so on, all addressed by the\n * `fixed32 key` from the announcement.\n *\n * This is deliberately driven by {@link KINDS} rather than by the object ids of any one product: a\n * ThirdReality speaker offers microphone gain, noise suppression and two wake-word sensitivities, a Home\n * Assistant Voice PE offers a different set, and both work without a change here. Only entity kinds a\n * voice satellite can plausibly carry are handled; lights, covers and the rest of the ESPHome zoo are\n * ignored, because this adapter drives speakers, not a general ESPHome bridge.\n */\nimport { pb, type PbMessage } from './esphomeProto';\n\n/** Entity kinds we understand. */\nexport type EntityKind =\n 'switch' | 'number' | 'select' | 'event' | 'update' | 'mediaPlayer' | 'sensor' | 'binarySensor' | 'textSensor';\n\n/** `EntityCategory` in api.proto — config/diagnostic entities are settings, not primary controls. */\nexport type EntityCategory = 'none' | 'config' | 'diagnostic';\nconst CATEGORIES: EntityCategory[] = ['none', 'config', 'diagnostic'];\n\n/** One entity as announced by the device, plus its last known value. */\nexport interface EsphomeEntity {\n kind: EntityKind;\n /** Stable id within the device (`mic_volume`), used as the ioBroker state name. */\n objectId: string;\n /** Wire handle the command messages address. */\n key: number;\n /** Human-readable name as the device labels it. */\n name: string;\n category: EntityCategory;\n icon?: string;\n /** Whether a value can be written back (false for `event`, which is read-only). */\n writable: boolean;\n /** number: range and unit. */\n min?: number;\n max?: number;\n step?: number;\n unit?: string;\n /** sensor: how many decimals the device considers meaningful. */\n decimals?: number;\n /** select: allowed values. */\n options?: string[];\n /** event: the types the device can fire. */\n eventTypes?: string[];\n /** mediaPlayer: whether it accepts a pause command. */\n supportsPause?: boolean;\n /** Last value the device reported; undefined until the first state message. */\n value?: EntityValue;\n}\n\n/** A value as it travels to and from ioBroker. `update` reports an object, the rest are scalars. */\nexport type EntityValue = boolean | number | string | UpdateInfo | MediaPlayerValue;\n\nexport interface UpdateInfo {\n currentVersion: string;\n latestVersion: string;\n title: string;\n inProgress: boolean;\n progress: number;\n releaseUrl: string;\n}\n\nexport interface MediaPlayerValue {\n state: 'none' | 'idle' | 'playing' | 'paused' | 'announcing' | 'off' | 'on';\n volume: number;\n muted: boolean;\n}\n\nconst MEDIA_STATES = ['none', 'idle', 'playing', 'paused', 'announcing', 'off', 'on'] as const;\n\n/** `MediaPlayerCommand` in api.proto, by the name we accept from ioBroker. */\nexport const MEDIA_COMMANDS: Record<string, number> = {\n play: 0,\n pause: 1,\n stop: 2,\n mute: 3,\n unmute: 4,\n toggle: 5,\n volume_up: 6,\n volume_down: 7,\n turn_on: 12,\n turn_off: 13,\n};\n\n/** `UpdateCommand` in api.proto. */\nexport const UPDATE_COMMANDS: Record<string, number> = { none: 0, install: 1, check: 2 };\n\n/**\n * Minimal structural typing over the generated announcement classes. Every `ListEntities*Response`\n * shares the first four fields, which is what makes the table-driven approach below possible.\n */\ninterface ListEntityMsg {\n getObjectId(): string;\n getKey(): number;\n getName(): string;\n getEntityCategory(): number;\n getIcon?(): string;\n getDisabledByDefault?(): boolean;\n}\n\n/** How one entity kind is announced, reported and written. */\ninterface KindSpec {\n kind: EntityKind;\n /** `ListEntities…Response` message name. */\n listMessage: string;\n /** `…StateResponse` message name (absent for `event`, which pushes `EventResponse`). */\n stateMessage: string;\n writable: boolean;\n /** Pull the kind-specific announcement fields out. */\n describe?: (msg: unknown) => Partial<EsphomeEntity>;\n /** Read the value out of a state message. */\n readState: (msg: unknown) => EntityValue | undefined;\n /** Build the command message that writes `value`, or null when the value makes no sense. */\n command: (entity: EsphomeEntity, value: unknown) => PbMessage | null;\n}\n\n/** Clamp to the device's own range — it ignores anything outside and we would never notice. */\nfunction clamp(entity: EsphomeEntity, value: number): number {\n const lo = entity.min ?? Number.NEGATIVE_INFINITY;\n const hi = entity.max ?? Number.POSITIVE_INFINITY;\n return Math.min(hi, Math.max(lo, value));\n}\n\nconst KINDS: KindSpec[] = [\n // ── read-only measurements ────────────────────────────────────────────────────────────────────\n // A voice satellite is also a box in a room: it usually reports its own temperature, whether\n // something was detected, or a status text. Read-only, so they only ever produce states.\n {\n kind: 'sensor',\n listMessage: 'ListEntitiesSensorResponse',\n stateMessage: 'SensorStateResponse',\n writable: false,\n describe: m => {\n const sensor = m as { getUnitOfMeasurement(): string; getAccuracyDecimals(): number };\n return {\n unit: sensor.getUnitOfMeasurement() || undefined,\n // The device tells us how many decimals are meaningful; more would be noise.\n decimals: sensor.getAccuracyDecimals(),\n };\n },\n readState: m => {\n const state = m as { getState(): number; getMissingState(): boolean };\n // `missing_state` means the device has no reading yet — not that the value is 0.\n return state.getMissingState() ? undefined : state.getState();\n },\n command: () => null,\n },\n {\n kind: 'binarySensor',\n listMessage: 'ListEntitiesBinarySensorResponse',\n stateMessage: 'BinarySensorStateResponse',\n writable: false,\n readState: m => {\n const state = m as { getState(): boolean; getMissingState(): boolean };\n return state.getMissingState() ? undefined : state.getState();\n },\n command: () => null,\n },\n {\n kind: 'textSensor',\n listMessage: 'ListEntitiesTextSensorResponse',\n stateMessage: 'TextSensorStateResponse',\n writable: false,\n readState: m => {\n const state = m as { getState(): string; getMissingState(): boolean };\n return state.getMissingState() ? undefined : state.getState();\n },\n command: () => null,\n },\n {\n kind: 'switch',\n listMessage: 'ListEntitiesSwitchResponse',\n stateMessage: 'SwitchStateResponse',\n writable: true,\n readState: m => (m as { getState(): boolean }).getState(),\n command: (entity, value) => {\n const message = new pb.SwitchCommandRequest();\n message.setKey(entity.key);\n message.setState(value === true || value === 'true' || value === 1);\n return message;\n },\n },\n {\n kind: 'number',\n listMessage: 'ListEntitiesNumberResponse',\n stateMessage: 'NumberStateResponse',\n writable: true,\n describe: m => {\n const n = m as {\n getMinValue(): number;\n getMaxValue(): number;\n getStep(): number;\n getUnitOfMeasurement(): string;\n };\n return { min: n.getMinValue(), max: n.getMaxValue(), step: n.getStep(), unit: n.getUnitOfMeasurement() };\n },\n readState: m => {\n const n = m as { getState(): number; getMissingState(): boolean };\n return n.getMissingState() ? undefined : n.getState();\n },\n command: (entity, value) => {\n const num = Number(value);\n if (!Number.isFinite(num)) {\n return null;\n }\n const message = new pb.NumberCommandRequest();\n message.setKey(entity.key);\n message.setState(clamp(entity, num));\n return message;\n },\n },\n {\n kind: 'select',\n listMessage: 'ListEntitiesSelectResponse',\n stateMessage: 'SelectStateResponse',\n writable: true,\n describe: m => ({ options: (m as { getOptionsList(): string[] }).getOptionsList() }),\n readState: m => {\n const s = m as { getState(): string; getMissingState(): boolean };\n return s.getMissingState() ? undefined : s.getState();\n },\n command: (entity, value) => {\n // Match case-insensitively: the device only accepts one of its own options verbatim.\n const wanted = String(value).trim().toLowerCase();\n const option = entity.options?.find(o => o.toLowerCase() === wanted);\n if (!option) {\n return null;\n }\n const message = new pb.SelectCommandRequest();\n message.setKey(entity.key);\n message.setState(option);\n return message;\n },\n },\n {\n kind: 'event',\n listMessage: 'ListEntitiesEventResponse',\n stateMessage: 'EventResponse',\n writable: false,\n describe: m => ({ eventTypes: (m as { getEventTypesList(): string[] }).getEventTypesList() }),\n readState: m => (m as { getEventType(): string }).getEventType(),\n command: () => null,\n },\n {\n kind: 'update',\n listMessage: 'ListEntitiesUpdateResponse',\n stateMessage: 'UpdateStateResponse',\n writable: true,\n readState: m => {\n const u = m as {\n getMissingState(): boolean;\n getCurrentVersion(): string;\n getLatestVersion(): string;\n getTitle(): string;\n getInProgress(): boolean;\n getProgress(): number;\n getReleaseUrl(): string;\n };\n if (u.getMissingState()) {\n return undefined;\n }\n return {\n currentVersion: u.getCurrentVersion(),\n latestVersion: u.getLatestVersion(),\n title: u.getTitle(),\n inProgress: u.getInProgress(),\n progress: u.getProgress(),\n releaseUrl: u.getReleaseUrl(),\n };\n },\n command: (entity, value) => {\n const command = UPDATE_COMMANDS[String(value).trim().toLowerCase()];\n if (command === undefined || command === 0) {\n return null;\n }\n const message = new pb.UpdateCommandRequest();\n message.setKey(entity.key);\n message.setCommand(command);\n return message;\n },\n },\n {\n kind: 'mediaPlayer',\n listMessage: 'ListEntitiesMediaPlayerResponse',\n stateMessage: 'MediaPlayerStateResponse',\n writable: true,\n describe: m => ({ supportsPause: (m as { getSupportsPause(): boolean }).getSupportsPause() }),\n readState: m => {\n const p = m as { getState(): number; getVolume(): number; getMuted(): boolean };\n return { state: MEDIA_STATES[p.getState()] ?? 'none', volume: p.getVolume(), muted: p.getMuted() };\n },\n command: (entity, value) => {\n const message = new pb.MediaPlayerCommandRequest();\n message.setKey(entity.key);\n // A number is a volume (0…1), a string is one of the named commands.\n if (typeof value === 'number' || (typeof value === 'string' && /^[\\d.]+$/.test(value.trim()))) {\n message.setHasVolume(true);\n message.setVolume(Math.min(1, Math.max(0, Number(value))));\n return message;\n }\n const command = MEDIA_COMMANDS[String(value).trim().toLowerCase()];\n if (command === undefined) {\n return null;\n }\n message.setHasCommand(true);\n message.setCommand(command);\n return message;\n },\n },\n];\n\nconst BY_LIST_MESSAGE = new Map(KINDS.map(k => [k.listMessage, k]));\nconst BY_STATE_MESSAGE = new Map(KINDS.map(k => [k.stateMessage, k]));\nconst BY_KIND = new Map(KINDS.map(k => [k.kind, k]));\n\n/** True when this message announces an entity kind we handle. */\nexport function isListEntityMessage(name: string): boolean {\n return BY_LIST_MESSAGE.has(name);\n}\n\n/** True when this message carries a state for an entity kind we handle. */\nexport function isEntityStateMessage(name: string): boolean {\n return BY_STATE_MESSAGE.has(name);\n}\n\n/**\n * The entities of one device. Announcements arrive once after `ListEntitiesRequest`; state messages\n * arrive continuously and are matched back by `key`.\n */\nexport class EntityRegistry {\n private readonly byKey = new Map<number, EsphomeEntity>();\n private readonly byObjectId = new Map<string, EsphomeEntity>();\n\n /** Every entity the device announced, in announcement order. */\n list(): EsphomeEntity[] {\n return [...this.byKey.values()];\n }\n\n get(objectId: string): EsphomeEntity | undefined {\n return this.byObjectId.get(objectId);\n }\n\n /** Forget everything — the device re-announces after every reconnect. */\n clear(): void {\n this.byKey.clear();\n this.byObjectId.clear();\n }\n\n /** Take a `ListEntities…Response`. Returns the entity, or null for a kind we ignore. */\n addFromAnnouncement(name: string, message: unknown): EsphomeEntity | null {\n const spec = BY_LIST_MESSAGE.get(name);\n if (!spec) {\n return null;\n }\n const base = message as ListEntityMsg;\n const entity: EsphomeEntity = {\n kind: spec.kind,\n objectId: base.getObjectId(),\n key: base.getKey(),\n name: base.getName(),\n category: CATEGORIES[base.getEntityCategory()] ?? 'none',\n icon: base.getIcon?.() || undefined,\n writable: spec.writable,\n ...spec.describe?.(message),\n };\n this.byKey.set(entity.key, entity);\n this.byObjectId.set(entity.objectId, entity);\n return entity;\n }\n\n /**\n * Take a `…StateResponse`. Returns the updated entity, or null when it belongs to an entity the\n * device never announced — which happens legitimately, because the device pushes the current state\n * of everything as soon as we subscribe, possibly before its announcements have arrived.\n */\n applyState(name: string, message: unknown): EsphomeEntity | null {\n const spec = BY_STATE_MESSAGE.get(name);\n if (!spec) {\n return null;\n }\n const entity = this.byKey.get((message as { getKey(): number }).getKey());\n if (!entity) {\n return null;\n }\n const value = spec.readState(message);\n if (value === undefined) {\n return null;\n }\n entity.value = value;\n return entity;\n }\n\n /**\n * Build the command message that writes `value` to `objectId`, or null when the entity is unknown,\n * read-only, or the value is not one it accepts.\n */\n command(objectId: string, value: unknown): PbMessage | null {\n const entity = this.byObjectId.get(objectId);\n if (!entity?.writable) {\n return null;\n }\n return BY_KIND.get(entity.kind)?.command(entity, value) ?? null;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"esphomeEntities.js","sourceRoot":"","sources":["../../../src/lib/voice/esphomeEntities.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The ESPHome entity layer: everything a voice satellite exposes *besides* the voice pipeline.\n *\n * A device announces its entities once, in response to `ListEntitiesRequest`, and then pushes a state\n * message whenever one changes. Each entity kind has its own pair of messages plus a command message to\n * write it back — `SwitchCommandRequest`, `NumberCommandRequest` and so on, all addressed by the\n * `fixed32 key` from the announcement.\n *\n * This is deliberately driven by {@link KINDS} rather than by the object ids of any one product: a\n * ThirdReality speaker offers microphone gain, noise suppression and two wake-word sensitivities, a Home\n * Assistant Voice PE offers a different set, and both work without a change here. Only entity kinds a\n * voice satellite can plausibly carry are handled; lights, covers and the rest of the ESPHome zoo are\n * ignored, because this adapter drives speakers, not a general ESPHome bridge.\n */\nimport { pb, type PbMessage } from './esphomeProto';\n\n/** Entity kinds we understand. */\nexport type EntityKind =\n 'switch' | 'number' | 'select' | 'event' | 'update' | 'mediaPlayer' | 'sensor' | 'binarySensor' | 'textSensor';\n\n/** `EntityCategory` in api.proto — config/diagnostic entities are settings, not primary controls. */\nexport type EntityCategory = 'none' | 'config' | 'diagnostic';\nconst CATEGORIES: EntityCategory[] = ['none', 'config', 'diagnostic'];\n\n/** One entity as announced by the device, plus its last known value. */\nexport interface EsphomeEntity {\n kind: EntityKind;\n /** Stable id within the device (`mic_volume`), used as the ioBroker state name. */\n objectId: string;\n /** Wire handle the command messages address. */\n key: number;\n /** Human-readable name as the device labels it. */\n name: string;\n category: EntityCategory;\n icon?: string;\n /** Whether a value can be written back (false for `event`, which is read-only). */\n writable: boolean;\n /** number: range and unit. */\n min?: number;\n max?: number;\n step?: number;\n unit?: string;\n /** sensor: how many decimals the device considers meaningful. */\n decimals?: number;\n /** select: allowed values. */\n options?: string[];\n /** event: the types the device can fire. */\n eventTypes?: string[];\n /** mediaPlayer: whether it accepts a pause command. */\n supportsPause?: boolean;\n /** Last value the device reported; undefined until the first state message. */\n value?: EntityValue;\n}\n\n/** A value as it travels to and from ioBroker. `update` reports an object, the rest are scalars. */\nexport type EntityValue = boolean | number | string | UpdateInfo | MediaPlayerValue;\n\nexport interface UpdateInfo {\n currentVersion: string;\n latestVersion: string;\n title: string;\n inProgress: boolean;\n progress: number;\n releaseUrl: string;\n}\n\nexport interface MediaPlayerValue {\n state: 'none' | 'idle' | 'playing' | 'paused' | 'announcing' | 'off' | 'on';\n volume: number;\n muted: boolean;\n}\n\nconst MEDIA_STATES = ['none', 'idle', 'playing', 'paused', 'announcing', 'off', 'on'] as const;\n\n/** `MediaPlayerCommand` in api.proto, by the name we accept from ioBroker. */\nexport const MEDIA_COMMANDS: Record<string, number> = {\n play: 0,\n pause: 1,\n stop: 2,\n mute: 3,\n unmute: 4,\n toggle: 5,\n volume_up: 6,\n volume_down: 7,\n turn_on: 12,\n turn_off: 13,\n};\n\n/** `UpdateCommand` in api.proto. */\nexport const UPDATE_COMMANDS: Record<string, number> = { none: 0, install: 1, check: 2 };\n\n/**\n * Minimal structural typing over the generated announcement classes. Every `ListEntities*Response`\n * shares the first four fields, which is what makes the table-driven approach below possible.\n */\ninterface ListEntityMsg {\n getObjectId(): string;\n getKey(): number;\n getName(): string;\n getEntityCategory(): number;\n getIcon?(): string;\n getDisabledByDefault?(): boolean;\n}\n\n/** How one entity kind is announced, reported and written. */\ninterface KindSpec {\n kind: EntityKind;\n /** `ListEntities…Response` message name. */\n listMessage: string;\n /** `…StateResponse` message name (absent for `event`, which pushes `EventResponse`). */\n stateMessage: string;\n writable: boolean;\n /** Pull the kind-specific announcement fields out. */\n describe?: (msg: unknown) => Partial<EsphomeEntity>;\n /** Read the value out of a state message. */\n readState: (msg: unknown) => EntityValue | undefined;\n /** Build the command message that writes `value`, or null when the value makes no sense. */\n command: (entity: EsphomeEntity, value: unknown) => PbMessage | null;\n}\n\n/**\n * A `float` field of the ESPHome API as the number the device meant. The protobuf floats are 32 bit, so 0.5 from\n * the device arrives as 0.5000000237487257 and a step of 0.001 as 0.0010000000474974513 — shown as is in ioBroker\n * and in the settings dialog. float32 carries about 7 significant digits; everything after that is noise.\n */\nexport function float32(value: number): number {\n return Number.isFinite(value) ? Number(value.toPrecision(7)) : value;\n}\n\n/** Clamp to the device's own range — it ignores anything outside and we would never notice. */\nfunction clamp(entity: EsphomeEntity, value: number): number {\n const lo = entity.min ?? Number.NEGATIVE_INFINITY;\n const hi = entity.max ?? Number.POSITIVE_INFINITY;\n return Math.min(hi, Math.max(lo, value));\n}\n\nconst KINDS: KindSpec[] = [\n // ── read-only measurements ────────────────────────────────────────────────────────────────────\n // A voice satellite is also a box in a room: it usually reports its own temperature, whether\n // something was detected, or a status text. Read-only, so they only ever produce states.\n {\n kind: 'sensor',\n listMessage: 'ListEntitiesSensorResponse',\n stateMessage: 'SensorStateResponse',\n writable: false,\n describe: m => {\n const sensor = m as { getUnitOfMeasurement(): string; getAccuracyDecimals(): number };\n return {\n unit: sensor.getUnitOfMeasurement() || undefined,\n // The device tells us how many decimals are meaningful; more would be noise.\n decimals: sensor.getAccuracyDecimals(),\n };\n },\n readState: m => {\n const state = m as { getState(): number; getMissingState(): boolean };\n // `missing_state` means the device has no reading yet — not that the value is 0.\n return state.getMissingState() ? undefined : float32(state.getState());\n },\n command: () => null,\n },\n {\n kind: 'binarySensor',\n listMessage: 'ListEntitiesBinarySensorResponse',\n stateMessage: 'BinarySensorStateResponse',\n writable: false,\n readState: m => {\n const state = m as { getState(): boolean; getMissingState(): boolean };\n return state.getMissingState() ? undefined : state.getState();\n },\n command: () => null,\n },\n {\n kind: 'textSensor',\n listMessage: 'ListEntitiesTextSensorResponse',\n stateMessage: 'TextSensorStateResponse',\n writable: false,\n readState: m => {\n const state = m as { getState(): string; getMissingState(): boolean };\n return state.getMissingState() ? undefined : state.getState();\n },\n command: () => null,\n },\n {\n kind: 'switch',\n listMessage: 'ListEntitiesSwitchResponse',\n stateMessage: 'SwitchStateResponse',\n writable: true,\n readState: m => (m as { getState(): boolean }).getState(),\n command: (entity, value) => {\n const message = new pb.SwitchCommandRequest();\n message.setKey(entity.key);\n message.setState(value === true || value === 'true' || value === 1);\n return message;\n },\n },\n {\n kind: 'number',\n listMessage: 'ListEntitiesNumberResponse',\n stateMessage: 'NumberStateResponse',\n writable: true,\n describe: m => {\n const n = m as {\n getMinValue(): number;\n getMaxValue(): number;\n getStep(): number;\n getUnitOfMeasurement(): string;\n };\n return {\n min: float32(n.getMinValue()),\n max: float32(n.getMaxValue()),\n step: float32(n.getStep()),\n unit: n.getUnitOfMeasurement(),\n };\n },\n readState: m => {\n const n = m as { getState(): number; getMissingState(): boolean };\n return n.getMissingState() ? undefined : float32(n.getState());\n },\n command: (entity, value) => {\n const num = Number(value);\n if (!Number.isFinite(num)) {\n return null;\n }\n const message = new pb.NumberCommandRequest();\n message.setKey(entity.key);\n message.setState(clamp(entity, num));\n return message;\n },\n },\n {\n kind: 'select',\n listMessage: 'ListEntitiesSelectResponse',\n stateMessage: 'SelectStateResponse',\n writable: true,\n describe: m => ({ options: (m as { getOptionsList(): string[] }).getOptionsList() }),\n readState: m => {\n const s = m as { getState(): string; getMissingState(): boolean };\n return s.getMissingState() ? undefined : s.getState();\n },\n command: (entity, value) => {\n // Match case-insensitively: the device only accepts one of its own options verbatim.\n const wanted = String(value).trim().toLowerCase();\n const option = entity.options?.find(o => o.toLowerCase() === wanted);\n if (!option) {\n return null;\n }\n const message = new pb.SelectCommandRequest();\n message.setKey(entity.key);\n message.setState(option);\n return message;\n },\n },\n {\n kind: 'event',\n listMessage: 'ListEntitiesEventResponse',\n stateMessage: 'EventResponse',\n writable: false,\n describe: m => ({ eventTypes: (m as { getEventTypesList(): string[] }).getEventTypesList() }),\n readState: m => (m as { getEventType(): string }).getEventType(),\n command: () => null,\n },\n {\n kind: 'update',\n listMessage: 'ListEntitiesUpdateResponse',\n stateMessage: 'UpdateStateResponse',\n writable: true,\n readState: m => {\n const u = m as {\n getMissingState(): boolean;\n getCurrentVersion(): string;\n getLatestVersion(): string;\n getTitle(): string;\n getInProgress(): boolean;\n getProgress(): number;\n getReleaseUrl(): string;\n };\n if (u.getMissingState()) {\n return undefined;\n }\n return {\n currentVersion: u.getCurrentVersion(),\n latestVersion: u.getLatestVersion(),\n title: u.getTitle(),\n inProgress: u.getInProgress(),\n progress: float32(u.getProgress()),\n releaseUrl: u.getReleaseUrl(),\n };\n },\n command: (entity, value) => {\n const command = UPDATE_COMMANDS[String(value).trim().toLowerCase()];\n if (command === undefined || command === 0) {\n return null;\n }\n const message = new pb.UpdateCommandRequest();\n message.setKey(entity.key);\n message.setCommand(command);\n return message;\n },\n },\n {\n kind: 'mediaPlayer',\n listMessage: 'ListEntitiesMediaPlayerResponse',\n stateMessage: 'MediaPlayerStateResponse',\n writable: true,\n describe: m => ({ supportsPause: (m as { getSupportsPause(): boolean }).getSupportsPause() }),\n readState: m => {\n const p = m as { getState(): number; getVolume(): number; getMuted(): boolean };\n return { state: MEDIA_STATES[p.getState()] ?? 'none', volume: float32(p.getVolume()), muted: p.getMuted() };\n },\n command: (entity, value) => {\n const message = new pb.MediaPlayerCommandRequest();\n message.setKey(entity.key);\n // A number is a volume (0…1), a string is one of the named commands.\n if (typeof value === 'number' || (typeof value === 'string' && /^[\\d.]+$/.test(value.trim()))) {\n message.setHasVolume(true);\n message.setVolume(Math.min(1, Math.max(0, Number(value))));\n return message;\n }\n const command = MEDIA_COMMANDS[String(value).trim().toLowerCase()];\n if (command === undefined) {\n return null;\n }\n message.setHasCommand(true);\n message.setCommand(command);\n return message;\n },\n },\n];\n\nconst BY_LIST_MESSAGE = new Map(KINDS.map(k => [k.listMessage, k]));\nconst BY_STATE_MESSAGE = new Map(KINDS.map(k => [k.stateMessage, k]));\nconst BY_KIND = new Map(KINDS.map(k => [k.kind, k]));\n\n/** True when this message announces an entity kind we handle. */\nexport function isListEntityMessage(name: string): boolean {\n return BY_LIST_MESSAGE.has(name);\n}\n\n/** True when this message carries a state for an entity kind we handle. */\nexport function isEntityStateMessage(name: string): boolean {\n return BY_STATE_MESSAGE.has(name);\n}\n\n/**\n * The entities of one device. Announcements arrive once after `ListEntitiesRequest`; state messages\n * arrive continuously and are matched back by `key`.\n */\nexport class EntityRegistry {\n private readonly byKey = new Map<number, EsphomeEntity>();\n private readonly byObjectId = new Map<string, EsphomeEntity>();\n\n /** Every entity the device announced, in announcement order. */\n list(): EsphomeEntity[] {\n return [...this.byKey.values()];\n }\n\n get(objectId: string): EsphomeEntity | undefined {\n return this.byObjectId.get(objectId);\n }\n\n /** Forget everything — the device re-announces after every reconnect. */\n clear(): void {\n this.byKey.clear();\n this.byObjectId.clear();\n }\n\n /** Take a `ListEntities…Response`. Returns the entity, or null for a kind we ignore. */\n addFromAnnouncement(name: string, message: unknown): EsphomeEntity | null {\n const spec = BY_LIST_MESSAGE.get(name);\n if (!spec) {\n return null;\n }\n const base = message as ListEntityMsg;\n const entity: EsphomeEntity = {\n kind: spec.kind,\n objectId: base.getObjectId(),\n key: base.getKey(),\n name: base.getName(),\n category: CATEGORIES[base.getEntityCategory()] ?? 'none',\n icon: base.getIcon?.() || undefined,\n writable: spec.writable,\n ...spec.describe?.(message),\n };\n this.byKey.set(entity.key, entity);\n this.byObjectId.set(entity.objectId, entity);\n return entity;\n }\n\n /**\n * Take a `…StateResponse`. Returns the updated entity, or null when it belongs to an entity the\n * device never announced — which happens legitimately, because the device pushes the current state\n * of everything as soon as we subscribe, possibly before its announcements have arrived.\n */\n applyState(name: string, message: unknown): EsphomeEntity | null {\n const spec = BY_STATE_MESSAGE.get(name);\n if (!spec) {\n return null;\n }\n const entity = this.byKey.get((message as { getKey(): number }).getKey());\n if (!entity) {\n return null;\n }\n const value = spec.readState(message);\n if (value === undefined) {\n return null;\n }\n entity.value = value;\n return entity;\n }\n\n /**\n * Build the command message that writes `value` to `objectId`, or null when the entity is unknown,\n * read-only, or the value is not one it accepts.\n */\n command(objectId: string, value: unknown): PbMessage | null {\n const entity = this.byObjectId.get(objectId);\n if (!entity?.writable) {\n return null;\n }\n return BY_KIND.get(entity.kind)?.command(entity, value) ?? null;\n }\n}\n"]}
|
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.CachedTts = exports.DEFAULT_CACHE_BYTES = exports.MAX_CACHE_CHARS = exports.MAX_TTS_CHARS = void 0;
|
|
37
|
+
exports.parseTtsMaxChars = parseTtsMaxChars;
|
|
38
|
+
exports.spokenLengthHint = spokenLengthHint;
|
|
37
39
|
exports.isSsml = isSsml;
|
|
38
40
|
exports.stripSsml = stripSsml;
|
|
39
41
|
exports.truncateForTts = truncateForTts;
|
|
@@ -59,6 +61,51 @@ const fs = __importStar(require("node:fs"));
|
|
|
59
61
|
const path = __importStar(require("node:path"));
|
|
60
62
|
/** Hard limit for a spoken text. Hannah's 400 characters ≈ 25 seconds of speech. */
|
|
61
63
|
exports.MAX_TTS_CHARS = 400;
|
|
64
|
+
/**
|
|
65
|
+
* The configured length limit for spoken text: a number ≥ 0 (0 = no limit), or undefined for the default
|
|
66
|
+
* ({@link MAX_TTS_CHARS}) — also for installations from before the setting and for a field the user emptied.
|
|
67
|
+
*/
|
|
68
|
+
function parseTtsMaxChars(value) {
|
|
69
|
+
if (value === '' || value === null || value === undefined) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const n = Math.round(Number(value));
|
|
73
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Instruction for a model whose answer will be spoken and cut at `limit` characters: answer within it instead
|
|
77
|
+
* of being cut mid-thought. Models cannot count characters, so the limit also comes as a number of sentences.
|
|
78
|
+
* Also asks not to read out state ids and serial numbers — over a speaker they are a minute of digits.
|
|
79
|
+
* Empty when there is no limit.
|
|
80
|
+
*/
|
|
81
|
+
function spokenLengthHint(limit, lang) {
|
|
82
|
+
if (!(limit > 0)) {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
const n = Math.max(1, Math.round(limit / 120));
|
|
86
|
+
if (lang === 'ru') {
|
|
87
|
+
const sentences = n === 1
|
|
88
|
+
? 'одно короткое предложение'
|
|
89
|
+
: n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)
|
|
90
|
+
? `${n} коротких предложения`
|
|
91
|
+
: `${n} коротких предложений`;
|
|
92
|
+
return (`Твой ответ зачитывается вслух и обрезается после ${limit} символов. Поэтому отвечай не длиннее ${limit} ` +
|
|
93
|
+
`символов (примерно ${sentences}), главное — в начале. Не зачитывай ID состояний, серийные номера и ` +
|
|
94
|
+
'длинные числа, если пользователь сам об этом не просит. Если нужно сказать больше, кратко подведи итог ' +
|
|
95
|
+
'и предложи рассказать подробнее.');
|
|
96
|
+
}
|
|
97
|
+
if (lang === 'de') {
|
|
98
|
+
const sentences = n === 1 ? 'einen kurzen Satz' : `${n} kurze Sätze`;
|
|
99
|
+
return (`Deine Antwort wird vorgelesen und nach ${limit} Zeichen abgeschnitten. Antworte deshalb in höchstens ` +
|
|
100
|
+
`${limit} Zeichen (etwa ${sentences}), das Wichtigste zuerst. Lies keine State-IDs, Seriennummern oder ` +
|
|
101
|
+
'langen Zahlen vor, außer der Nutzer fragt ausdrücklich danach. Ist mehr zu sagen, fasse zusammen und ' +
|
|
102
|
+
'biete an, Details zu nennen.');
|
|
103
|
+
}
|
|
104
|
+
const sentences = n === 1 ? 'one short sentence' : `${n} short sentences`;
|
|
105
|
+
return (`Your answer is read aloud and cut off after ${limit} characters. Keep it within ${limit} characters ` +
|
|
106
|
+
`(about ${sentences}), most important point first. Do not read out state ids, serial numbers or long ` +
|
|
107
|
+
'numbers unless the user asks for them. If there is more to say, summarise and offer the details.');
|
|
108
|
+
}
|
|
62
109
|
/** Texts up to this length are worth caching; a longer one is a one-off answer. */
|
|
63
110
|
exports.MAX_CACHE_CHARS = 200;
|
|
64
111
|
/** Default budget for the cache directory. ~1 MB per 20 s of 24 kHz 16-bit audio. */
|
|
@@ -136,7 +183,8 @@ class CachedTts {
|
|
|
136
183
|
async synthesize(text, lang) {
|
|
137
184
|
const wanted = this.maxChars > 0 ? truncateForTts(text, this.maxChars) : (text || '').trim();
|
|
138
185
|
if (wanted.length < (text || '').trim().length) {
|
|
139
|
-
this.opts.log.warn(`TTS text shortened from ${text.trim().length} to ${wanted.length} characters
|
|
186
|
+
this.opts.log.warn(`TTS text shortened from ${text.trim().length} to ${wanted.length} characters ` +
|
|
187
|
+
`(limit ${this.maxChars}: "Longest spoken answer" in the Voice tab, 0 = no limit).`);
|
|
140
188
|
}
|
|
141
189
|
const cacheable = this.maxCacheChars === 0 || wanted.length <= this.maxCacheChars;
|
|
142
190
|
const file = cacheable ? this.entryPath(wanted, lang) : '';
|
|
@@ -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"]}
|
package/build/lib/voice/vad.js
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|
|
@@ -1329,6 +1361,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1329
1361
|
unit: entity.unit || undefined,
|
|
1330
1362
|
};
|
|
1331
1363
|
await this.setObjectNotExistsAsync(id, { type: 'state', common, native: {} });
|
|
1364
|
+
if (entity.kind === 'number') {
|
|
1365
|
+
// Objects from before float32() kept the raw float noise (step 0.0010000000474974513);
|
|
1366
|
+
// the device may also change its range with a firmware update.
|
|
1367
|
+
await this.syncNumberRange(id, entity);
|
|
1368
|
+
}
|
|
1332
1369
|
this.subscribeStates(id);
|
|
1333
1370
|
}
|
|
1334
1371
|
}
|
|
@@ -1367,6 +1404,18 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1367
1404
|
await ready;
|
|
1368
1405
|
await this.writeSatelliteControl(satId, entity);
|
|
1369
1406
|
}
|
|
1407
|
+
/** Bring min/max/step of an existing number control in line with what the device announces now. */
|
|
1408
|
+
async syncNumberRange(id, entity) {
|
|
1409
|
+
const obj = await this.getObjectAsync(id);
|
|
1410
|
+
if (!obj || obj.type !== 'state') {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const wanted = { min: entity.min, max: entity.max, step: entity.step };
|
|
1414
|
+
const common = obj.common;
|
|
1415
|
+
if (common.min !== wanted.min || common.max !== wanted.max || common.step !== wanted.step) {
|
|
1416
|
+
await this.extendObjectAsync(id, { common: wanted });
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1370
1419
|
/** Write one entity's current value into its state(s). Always acked — this is the device talking. */
|
|
1371
1420
|
async writeSatelliteControl(satId, entity) {
|
|
1372
1421
|
const base = `satellites.${satId}.controls.${entity.objectId}`;
|
|
@@ -1384,7 +1433,15 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1384
1433
|
await this.setStateAsync(`${base}.progress`, { val: v.progress, ack: true });
|
|
1385
1434
|
}
|
|
1386
1435
|
else {
|
|
1387
|
-
|
|
1436
|
+
let val = entity.value;
|
|
1437
|
+
// The device says how many decimals of a measurement mean something; the rest is noise.
|
|
1438
|
+
if (entity.kind === 'sensor' &&
|
|
1439
|
+
typeof val === 'number' &&
|
|
1440
|
+
entity.decimals !== undefined &&
|
|
1441
|
+
entity.decimals >= 0) {
|
|
1442
|
+
val = Number(val.toFixed(Math.min(entity.decimals, 10)));
|
|
1443
|
+
}
|
|
1444
|
+
await this.setStateAsync(base, { val, ack: true });
|
|
1388
1445
|
}
|
|
1389
1446
|
}
|
|
1390
1447
|
/**
|
|
@@ -2675,7 +2732,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2675
2732
|
* later turns have the full thread.
|
|
2676
2733
|
*/
|
|
2677
2734
|
answer(question, source = '', origin = {}) {
|
|
2678
|
-
|
|
2735
|
+
const voice = VOICE_TRANSPORTS.has(origin.transport || '');
|
|
2736
|
+
return this.requestOrigin.run({ source, room: origin.room || '', voice }, () => this.journal
|
|
2679
2737
|
? this.journal.run(question, source, origin, () => {
|
|
2680
2738
|
this.traceSpeechInput(question, origin);
|
|
2681
2739
|
return this.answerTracked(question, source);
|
|
@@ -2775,6 +2833,18 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2775
2833
|
}
|
|
2776
2834
|
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
2835
|
}
|
|
2836
|
+
/**
|
|
2837
|
+
* Length instruction for a request that came in by voice: the answer is cut at `ttsMaxChars` before it is
|
|
2838
|
+
* spoken, so the model is asked to stay within it. Empty for text requests (chat, Telegram, scripts) and
|
|
2839
|
+
* when the limit is off.
|
|
2840
|
+
*/
|
|
2841
|
+
spokenLengthHint() {
|
|
2842
|
+
if (!this.config.voiceEnabled || !this.requestOrigin.getStore()?.voice) {
|
|
2843
|
+
return '';
|
|
2844
|
+
}
|
|
2845
|
+
const limit = (0, ttsCache_1.parseTtsMaxChars)(this.config.ttsMaxChars) ?? ttsCache_1.MAX_TTS_CHARS;
|
|
2846
|
+
return (0, ttsCache_1.spokenLengthHint)(limit, String(this.config.voiceLanguage || this.language || 'en'));
|
|
2847
|
+
}
|
|
2778
2848
|
/** What [[DONE]] says where no beep can be played */
|
|
2779
2849
|
doneText() {
|
|
2780
2850
|
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
@@ -3061,9 +3131,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3061
3131
|
const sys = parts.length ? parts.join('\n\n') : undefined;
|
|
3062
3132
|
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
3133
|
// 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.
|
|
3134
|
+
// cache-safe, see buildTimeContext. The stored history keeps the untouched question. A spoken request
|
|
3135
|
+
// also gets the length limit, after the question: in the system prompt it would bust the prompt cache
|
|
3136
|
+
// every time voice and text requests alternate.
|
|
3065
3137
|
const time = this.buildTimeContext();
|
|
3066
|
-
const
|
|
3138
|
+
const lengthHint = this.spokenLengthHint();
|
|
3139
|
+
const questionForLlm = [time, weather, question, lengthHint].filter(Boolean).join('\n\n');
|
|
3067
3140
|
this.journal?.note({
|
|
3068
3141
|
tier: 'cloud',
|
|
3069
3142
|
detail: `${this.llm?.provider || this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
|
|
@@ -3073,11 +3146,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3073
3146
|
const deviceLines = ctx ? ctx.split('\n').length - 1 : 0;
|
|
3074
3147
|
this.journal?.step({
|
|
3075
3148
|
stage: 'context',
|
|
3076
|
-
title: `Prompt: ${deviceLines} devices${mem ? ', memory' : ''}${weather ? ', weather' : ''}${followUp ? ', follow-up marker' : ''}`,
|
|
3149
|
+
title: `Prompt: ${deviceLines} devices${mem ? ', memory' : ''}${weather ? ', weather' : ''}${followUp ? ', follow-up marker' : ''}${lengthHint ? ', length limit' : ''}`,
|
|
3077
3150
|
data: {
|
|
3078
3151
|
systemPrompt: this.config.systemPrompt || '',
|
|
3079
3152
|
followUpHint: followUp || undefined,
|
|
3080
3153
|
doneHint: done || undefined,
|
|
3154
|
+
lengthHint: lengthHint || undefined,
|
|
3081
3155
|
memory: mem || undefined,
|
|
3082
3156
|
devices: deviceLines,
|
|
3083
3157
|
time,
|
|
@@ -3114,7 +3188,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3114
3188
|
const followUp = this.config.voiceEnabled ? this.followUpHint() : '';
|
|
3115
3189
|
// The device list maps names to state ids, so pi-agent can act without searching first. It is the
|
|
3116
3190
|
// 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()]);
|
|
3191
|
+
const { text: instructions, dropped } = (0, piAgent_1.composeInstructions)([this.config.systemPrompt || '', followUp, this.doneHint(), this.spokenLengthHint()], [this.buildMemoryContext(), await this.buildDeviceContext()]);
|
|
3118
3192
|
if (dropped) {
|
|
3119
3193
|
this.log.debug(`pi-agent: ${dropped} context block(s) left out, the instructions would be too long.`);
|
|
3120
3194
|
}
|