iobroker.assistant 1.4.4 → 1.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/admin/custom/.vite/manifest.json +1 -1
- package/admin/custom/assets/Components-BR57MZLJ.js +2 -0
- package/admin/custom/customComponents.js +1 -1
- package/admin/custom/i18n/de.json +3 -0
- package/admin/custom/i18n/en.json +3 -0
- package/admin/custom/i18n/es.json +3 -0
- package/admin/custom/i18n/fr.json +3 -0
- package/admin/custom/i18n/it.json +3 -0
- package/admin/custom/i18n/nl.json +3 -0
- package/admin/custom/i18n/pl.json +3 -0
- package/admin/custom/i18n/pt.json +3 -0
- package/admin/custom/i18n/ru.json +3 -0
- package/admin/custom/i18n/uk.json +3 -0
- package/admin/custom/i18n/zh-cn.json +3 -0
- package/admin/custom/index.html +1 -1
- package/admin/custom/mf-manifest.json +1 -1
- package/admin/custom/mf-stats.json +1 -1
- package/admin/i18n/de.json +6 -2
- package/admin/i18n/en.json +6 -2
- package/admin/i18n/es.json +6 -2
- package/admin/i18n/fr.json +6 -2
- package/admin/i18n/it.json +6 -2
- package/admin/i18n/nl.json +6 -2
- package/admin/i18n/pl.json +6 -2
- package/admin/i18n/pt.json +6 -2
- package/admin/i18n/ru.json +6 -2
- package/admin/i18n/uk.json +6 -2
- package/admin/i18n/zh-cn.json +6 -2
- package/admin/jsonConfig.json +24 -2
- package/build/lib/voice/engines.js +3 -0
- package/build/lib/voice/engines.js.map +1 -1
- package/build/lib/voice/esphome.js +160 -19
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/esphomeEntities.js +76 -6
- package/build/lib/voice/esphomeEntities.js.map +1 -1
- package/build/lib/voice/esphomeProto.js.map +1 -1
- package/build/lib/voice/flac.js +162 -0
- package/build/lib/voice/flac.js.map +1 -0
- package/build/lib/voice/mediaServer.js +8 -1
- package/build/lib/voice/mediaServer.js.map +1 -1
- package/build/lib/voice/replyFormat.js +158 -0
- package/build/lib/voice/replyFormat.js.map +1 -0
- package/build/lib/voice/stt.js +37 -6
- package/build/lib/voice/stt.js.map +1 -1
- package/build/main.js +165 -4
- package/build/main.js.map +1 -1
- package/io-package.json +33 -29
- package/package.json +1 -1
- package/admin/custom/assets/Components-CCl7INIF.js +0 -2
|
@@ -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/**\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"]}
|
|
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';\nimport type { MediaFormat } from './replyFormat';\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 /** mediaPlayer: the audio formats it plays (empty on devices that do not say). */\n formats?: MediaFormat[];\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 => {\n const player = m as {\n getSupportsPause(): boolean;\n getSupportedFormatsList?(): {\n getFormat(): string;\n getSampleRate(): number;\n getNumChannels(): number;\n getPurpose(): number;\n getSampleBytes(): number;\n }[];\n };\n return {\n supportsPause: player.getSupportsPause(),\n formats: (player.getSupportedFormatsList?.() ?? []).map(f => ({\n format: f.getFormat(),\n sampleRate: f.getSampleRate(),\n channels: f.getNumChannels(),\n purpose: f.getPurpose() === 1 ? 'announcement' : 'default',\n sampleBytes: f.getSampleBytes(),\n })),\n };\n },\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/**\n * ESPHome's object id for an entity name, as the firmware computes it (`EntityBase::write_object_id_to`): byte by\n * byte, a space becomes `_`, A–Z lower case, anything else outside `[-_0-9a-z]` `_`.\n */\nexport function esphomeObjectId(name: string): string {\n let out = '';\n for (const byte of Buffer.from(name, 'utf8')) {\n const c = byte === 0x20 ? 0x5f : byte >= 0x41 && byte <= 0x5a ? byte + 0x20 : byte;\n const keep = c === 0x2d || c === 0x5f || (c >= 0x30 && c <= 0x39) || (c >= 0x61 && c <= 0x7a);\n out += keep ? String.fromCharCode(c) : '_';\n }\n return out;\n}\n\n/**\n * The state name of an entity. ESPHome 2026 (API 1.19) no longer sends `object_id` at all — only `key` and `name` —\n * so it has to be derived again (field report 2026-11-03: every control of a device ended up as\n * `satellites.<id>.controls.`, an invalid id). A Latin name gets exactly the id older firmware sent, so the\n * existing objects stay in use. A name in another script would come out as nothing but underscores under ESPHome's\n * rule, so it keeps its letters (ioBroker ids allow them); without any letter or digit the kind and key are left.\n *\n * @param announced `object_id` from the device ('' on current firmware)\n * @param name the entity's name, or the device's friendly name for an entity without one of its own\n */\nexport function entityObjectId(announced: string, name: string, kind: EntityKind, key: number): string {\n if (announced) {\n return announced;\n }\n // eslint-disable-next-line no-control-regex\n if (/^[\\x00-\\x7f]*$/.test(name) && /[a-z0-9]/i.test(name)) {\n return esphomeObjectId(name);\n }\n const unicode = name\n .trim()\n .toLowerCase()\n .replace(/\\s+/g, '_')\n .replace(/[^\\p{Ll}\\p{Lu}\\p{Nd}_-]/gu, '_');\n if (/[\\p{Ll}\\p{Lu}\\p{Nd}]/u.test(unicode)) {\n return unicode;\n }\n return `${kind.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`)}_${key.toString(16)}`;\n}\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 /**\n * Take a `ListEntities…Response`. Returns the entity, or null for a kind we ignore.\n *\n * @param name message name\n * @param message the announcement\n * @param deviceName the device's friendly name: an entity without a name of its own (the media player of a\n * Voice PE) is named after the device — current firmware sends such a name empty.\n */\n addFromAnnouncement(name: string, message: unknown, deviceName = ''): 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 key = base.getKey();\n const label = base.getName() || deviceName;\n let objectId = entityObjectId(base.getObjectId(), label, spec.kind, key);\n const taken = this.byObjectId.get(objectId);\n if (taken && taken.key !== key) {\n // Two entities with the same name (on different sub-devices): keep both apart.\n objectId = `${objectId}_${key.toString(16)}`;\n }\n const entity: EsphomeEntity = {\n kind: spec.kind,\n objectId,\n key,\n name: label,\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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"esphomeProto.js","sourceRoot":"","sources":["../../../src/lib/voice/esphomeProto.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ESPHome native-API wire codec (plain-text framing) — the transport half of `esphome.ts`.\n *\n * Frame layout: `0x00 | varint(payload length) | varint(message id) | payload (protobuf)`.\n *\n * Only the generated protobuf classes of `@2colors/esphome-native-api` are reused, not its\n * `Connection`/`FrameHelper`: that package's id↔type table stops at the Bluetooth range and omits the\n * whole VoiceAssistant block (89–92, 106, 115, 119–123). Worse, an unknown id makes its frame helper\n * throw *without advancing the read buffer*, so the connection stalls instead of skipping the message.\n * The registry here is derived from the `api.proto` the package ships, which covers every message the\n * device can send, and unknown ids are skipped by their declared length.\n *\n * The device speaks plain text (no noise encryption) — confirmed in the firmware's\n * `linux-voice-assistant-cpp` README (\"ESPHome native API server (TCP 6053, plain-text framing,\n * protobuf)\") — so the encrypted variant is deliberately not implemented; `decode` reports it clearly\n * if a device ever shows up with encryption switched on.\n */\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\n// ── minimal structural typing over the generated protobuf classes ───────────────────────────────────\n\nexport interface PbMessage {\n serializeBinary(): Uint8Array;\n}\n\n/** Messages we build and send (client → device). */\nexport interface HelloRequest extends PbMessage {\n setClientInfo(value: string): void;\n setApiVersionMajor(value: number): void;\n setApiVersionMinor(value: number): void;\n}\nexport interface AuthenticationRequest extends PbMessage {\n setPassword(value: string): void;\n}\nexport interface SubscribeVoiceAssistantRequest extends PbMessage {\n setSubscribe(value: boolean): void;\n setFlags(value: number): void;\n}\nexport interface VoiceAssistantResponseMsg extends PbMessage {\n setPort(value: number): void;\n setError(value: boolean): void;\n}\nexport interface VoiceAssistantEventData extends PbMessage {\n setName(value: string): void;\n setValue(value: string): void;\n}\nexport interface VoiceAssistantEventResponse extends PbMessage {\n setEventType(value: number): void;\n setDataList(value: VoiceAssistantEventData[]): void;\n}\nexport interface VoiceAssistantAnnounceRequest extends PbMessage {\n setMediaId(value: string): void;\n setText(value: string): void;\n setPreannounceMediaId(value: string): void;\n setStartConversation(value: boolean): void;\n}\nexport interface GetTimeResponse extends PbMessage {\n setEpochSeconds(value: number): void;\n}\n/** Selects which of the device's wake words are listening; the device persists this itself. */\nexport interface VoiceAssistantSetConfiguration extends PbMessage {\n setActiveWakeWordsList(value: string[]): void;\n}\n/** A model the device may download when it is selected (custom wake words). */\nexport interface VoiceAssistantExternalWakeWord extends PbMessage {\n setId(value: string): void;\n setWakeWord(value: string): void;\n setTrainedLanguagesList(value: string[]): void;\n setModelType(value: string): void;\n setModelSize(value: number): void;\n setModelHash(value: string): void;\n setUrl(value: string): void;\n}\n/** Asks for the wake-word configuration — and offers the external ones, replacing the last offer. */\nexport interface VoiceAssistantConfigurationRequest extends PbMessage {\n setExternalWakeWordsList(value: VoiceAssistantExternalWakeWord[]): void;\n}\n\n// Entity command messages. All address an entity by the `fixed32 key` from its announcement.\nexport interface SwitchCommandRequest extends PbMessage {\n setKey(value: number): void;\n setState(value: boolean): void;\n}\nexport interface NumberCommandRequest extends PbMessage {\n setKey(value: number): void;\n setState(value: number): void;\n}\nexport interface SelectCommandRequest extends PbMessage {\n setKey(value: number): void;\n setState(value: string): void;\n}\nexport interface UpdateCommandRequest extends PbMessage {\n setKey(value: number): void;\n setCommand(value: number): void;\n}\nexport interface MediaPlayerCommandRequest extends PbMessage {\n setKey(value: number): void;\n setHasCommand(value: boolean): void;\n setCommand(value: number): void;\n setHasVolume(value: boolean): void;\n setVolume(value: number): void;\n}\n/** Mirrors an assistant timer onto the device, which drives its own LED ring and gong. */\nexport interface VoiceAssistantTimerEventResponse extends PbMessage {\n setEventType(value: number): void;\n setTimerId(value: string): void;\n setName(value: string): void;\n setTotalSeconds(value: number): void;\n setSecondsLeft(value: number): void;\n setIsActive(value: boolean): void;\n}\n\n/** Messages we receive (device → client). */\nexport interface HelloResponse {\n getApiVersionMajor(): number;\n getApiVersionMinor(): number;\n getServerInfo(): string;\n getName(): string;\n}\nexport interface AuthenticationResponse {\n getInvalidPassword(): boolean;\n}\nexport interface DeviceInfoResponse {\n getName(): string;\n getModel(): string;\n getEsphomeVersion(): string;\n getMacAddress(): string;\n}\nexport interface VoiceAssistantWakeWord {\n getId(): string;\n getWakeWord(): string;\n /** ISO-639-1 codes the model was trained on — only `okay_nabu` covers more than English. */\n getTrainedLanguagesList(): string[];\n}\nexport interface VoiceAssistantConfigurationResponse {\n getAvailableWakeWordsList(): VoiceAssistantWakeWord[];\n getActiveWakeWordsList(): string[];\n /** How many wake words may listen at once (2 on the ThirdReality speaker). */\n getMaxActiveWakeWords(): number;\n}\nexport interface VoiceAssistantRequestMsg {\n getStart(): boolean;\n getConversationId(): string;\n getWakeWordPhrase(): string;\n}\nexport interface VoiceAssistantAudio {\n getData_asU8(): Uint8Array;\n getEnd(): boolean;\n}\nexport interface VoiceAssistantAnnounceFinished {\n getSuccess(): boolean;\n}\n\ntype Ctor<T> = new () => T;\n\ninterface ApiPb {\n HelloRequest: Ctor<HelloRequest>;\n AuthenticationRequest: Ctor<AuthenticationRequest>;\n DeviceInfoRequest: Ctor<PbMessage>;\n ListEntitiesRequest: Ctor<PbMessage>;\n SubscribeStatesRequest: Ctor<PbMessage>;\n SubscribeVoiceAssistantRequest: Ctor<SubscribeVoiceAssistantRequest>;\n VoiceAssistantConfigurationRequest: Ctor<VoiceAssistantConfigurationRequest>;\n VoiceAssistantExternalWakeWord: Ctor<VoiceAssistantExternalWakeWord>;\n VoiceAssistantSetConfiguration: Ctor<VoiceAssistantSetConfiguration>;\n VoiceAssistantTimerEventResponse: Ctor<VoiceAssistantTimerEventResponse>;\n SwitchCommandRequest: Ctor<SwitchCommandRequest>;\n NumberCommandRequest: Ctor<NumberCommandRequest>;\n SelectCommandRequest: Ctor<SelectCommandRequest>;\n UpdateCommandRequest: Ctor<UpdateCommandRequest>;\n MediaPlayerCommandRequest: Ctor<MediaPlayerCommandRequest>;\n VoiceAssistantResponse: Ctor<VoiceAssistantResponseMsg>;\n VoiceAssistantEventResponse: Ctor<VoiceAssistantEventResponse>;\n VoiceAssistantEventData: Ctor<VoiceAssistantEventData>;\n VoiceAssistantAnnounceRequest: Ctor<VoiceAssistantAnnounceRequest>;\n PingRequest: Ctor<PbMessage>;\n PingResponse: Ctor<PbMessage>;\n GetTimeResponse: Ctor<GetTimeResponse>;\n DisconnectRequest: Ctor<PbMessage>;\n DisconnectResponse: Ctor<PbMessage>;\n [name: string]: Ctor<unknown> | undefined;\n}\n\nconst API_PB_MODULE = '@2colors/esphome-native-api/lib/protoc/api_pb';\n\nexport const pb = require(API_PB_MODULE) as ApiPb;\n\n// ── message registry, built from the shipped api.proto ──────────────────────────────────────────────\n\n/** Deserialiser for one message id. */\ninterface Deserialiser {\n deserializeBinary(bytes: Uint8Array): unknown;\n}\n\nconst idToName = new Map<number, string>();\nconst classById = new Map<number, Deserialiser>();\n/** Message class → its wire id, so `encode` can find the id from an instance. */\nconst idByCtor = new Map<unknown, number>();\n\nfunction buildRegistry(): void {\n const protoFile = path.join(path.dirname(require.resolve(API_PB_MODULE)), 'api.proto');\n const source = fs.readFileSync(protoFile, 'utf8');\n let current: string | null = null;\n\n for (const raw of source.split(/\\r?\\n/)) {\n const line = raw.trim();\n const message = /^message\\s+(\\w+)\\s*\\{/.exec(line);\n if (message) {\n current = message[1];\n continue;\n }\n if (line === '}') {\n current = null;\n continue;\n }\n const option = /^option\\s*\\(\\s*id\\s*\\)\\s*=\\s*(\\d+)\\s*;/.exec(line);\n if (!option || !current) {\n continue;\n }\n const ctor = pb[current];\n if (!ctor) {\n continue; // in the .proto but not in the generated JS — nothing we can do with it\n }\n const id = Number(option[1]);\n idToName.set(id, current);\n classById.set(id, ctor as unknown as Deserialiser);\n idByCtor.set(ctor, id);\n }\n}\nbuildRegistry();\n\n/** Number of messages the registry knows (130 with the shipped api.proto) — used by the unit test. */\nexport const registrySize = idToName.size;\n\n// ── varint helpers ──────────────────────────────────────────────────────────────────────────────────\n\nfunction encodeVarint(value: number): number[] {\n const out: number[] = [];\n let rest = value >>> 0;\n do {\n let byte = rest & 0x7f;\n rest >>>= 7;\n if (rest) {\n byte |= 0x80;\n }\n out.push(byte);\n } while (rest);\n return out;\n}\n\n/** Read a varint at `offset`; null when the buffer holds only part of it (wait for more data). */\nfunction readVarint(buf: Buffer, offset: number): { value: number; offset: number } | null {\n let result = 0;\n let shift = 0;\n let pos = offset;\n while (pos < buf.length) {\n const byte = buf[pos++];\n result |= (byte & 0x7f) << shift;\n if (!(byte & 0x80)) {\n return { value: result >>> 0, offset: pos };\n }\n shift += 7;\n if (shift > 28) {\n throw new Error('malformed varint');\n }\n }\n return null;\n}\n\n// ── framing ─────────────────────────────────────────────────────────────────────────────────────────\n\n/** Serialise one protobuf message into a complete frame. */\nexport function encode(message: PbMessage): Buffer {\n const id = idByCtor.get((message as object).constructor);\n if (id === undefined) {\n throw new Error('message class is not registered in the ESPHome id table');\n }\n const payload = Buffer.from(message.serializeBinary());\n return Buffer.concat([Buffer.from([0, ...encodeVarint(payload.length), ...encodeVarint(id)]), payload]);\n}\n\nexport interface DecodedFrame {\n id: number;\n /** null for a message id the registry doesn't know — the frame was skipped by its declared length. */\n name: string | null;\n /** The decoded message, or null when the id is unknown or the payload didn't parse. */\n message: unknown;\n /** Set when a known message failed to deserialise. */\n error: Error | null;\n}\n\n/**\n * Pull every complete frame out of `buffer`. Returns the frames plus the unconsumed tail; the input is\n * never mutated, so the caller just keeps assigning `rest` back to its receive buffer.\n */\nexport function decode(buffer: Buffer): { frames: DecodedFrame[]; rest: Buffer } {\n const frames: DecodedFrame[] = [];\n let offset = 0;\n\n while (offset < buffer.length) {\n if (buffer[offset] !== 0) {\n throw new Error(\n buffer[offset] === 1\n ? 'device uses the encrypted (noise) ESPHome API — only plain text is supported'\n : `bad frame preamble 0x${buffer[offset].toString(16)}`,\n );\n }\n const length = readVarint(buffer, offset + 1);\n if (!length) {\n break;\n }\n const id = readVarint(buffer, length.offset);\n if (!id) {\n break;\n }\n const end = id.offset + length.value;\n if (end > buffer.length) {\n break; // frame not fully received yet\n }\n\n const payload = buffer.subarray(id.offset, end);\n const name = idToName.get(id.value) ?? null;\n const ctor = classById.get(id.value);\n let message: unknown = null;\n let error: Error | null = null;\n if (ctor) {\n try {\n message = ctor.deserializeBinary(payload);\n } catch (e) {\n error = e as Error;\n }\n }\n frames.push({ id: id.value, name, message, error });\n offset = end;\n }\n\n return { frames, rest: buffer.subarray(offset) };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"esphomeProto.js","sourceRoot":"","sources":["../../../src/lib/voice/esphomeProto.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmRA,wBAOC;AAgBD,wBA0CC;AApVD;;;;;;;;;;;;;;;;GAgBG;AACH,4CAA8B;AAC9B,gDAAkC;AAwKlC,MAAM,aAAa,GAAG,+CAA+C,CAAC;AAEzD,QAAA,EAAE,GAAG,OAAO,CAAC,aAAa,CAAU,CAAC;AASlD,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkB,CAAC;AAC3C,MAAM,SAAS,GAAG,IAAI,GAAG,EAAwB,CAAC;AAClD,iFAAiF;AACjF,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAmB,CAAC;AAE5C,SAAS,aAAa;IAClB,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;IACvF,MAAM,MAAM,GAAG,EAAE,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;IAClD,IAAI,OAAO,GAAkB,IAAI,CAAC;IAElC,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QACtC,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;QACxB,MAAM,OAAO,GAAG,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACnD,IAAI,OAAO,EAAE,CAAC;YACV,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;YACrB,SAAS;QACb,CAAC;QACD,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;YACf,OAAO,GAAG,IAAI,CAAC;YACf,SAAS;QACb,CAAC;QACD,MAAM,MAAM,GAAG,wCAAwC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACnE,IAAI,CAAC,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;YACtB,SAAS;QACb,CAAC;QACD,MAAM,IAAI,GAAG,UAAE,CAAC,OAAO,CAAC,CAAC;QACzB,IAAI,CAAC,IAAI,EAAE,CAAC;YACR,SAAS,CAAC,wEAAwE;QACtF,CAAC;QACD,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7B,QAAQ,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;QAC1B,SAAS,CAAC,GAAG,CAAC,EAAE,EAAE,IAA+B,CAAC,CAAC;QACnD,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;IAC3B,CAAC;AACL,CAAC;AACD,aAAa,EAAE,CAAC;AAEhB,sGAAsG;AACzF,QAAA,YAAY,GAAG,QAAQ,CAAC,IAAI,CAAC;AAE1C,uGAAuG;AAEvG,SAAS,YAAY,CAAC,KAAa;IAC/B,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,IAAI,IAAI,GAAG,KAAK,KAAK,CAAC,CAAC;IACvB,GAAG,CAAC;QACA,IAAI,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;QACvB,IAAI,MAAM,CAAC,CAAC;QACZ,IAAI,IAAI,EAAE,CAAC;YACP,IAAI,IAAI,IAAI,CAAC;QACjB,CAAC;QACD,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACnB,CAAC,QAAQ,IAAI,EAAE;IACf,OAAO,GAAG,CAAC;AACf,CAAC;AAED,kGAAkG;AAClG,SAAS,UAAU,CAAC,GAAW,EAAE,MAAc;IAC3C,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,GAAG,GAAG,MAAM,CAAC;IACjB,OAAO,GAAG,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;QACtB,MAAM,IAAI,GAAG,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC;QACxB,MAAM,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,KAAK,CAAC;QACjC,IAAI,CAAC,CAAC,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC;YACjB,OAAO,EAAE,KAAK,EAAE,MAAM,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;QAChD,CAAC;QACD,KAAK,IAAI,CAAC,CAAC;QACX,IAAI,KAAK,GAAG,EAAE,EAAE,CAAC;YACb,MAAM,IAAI,KAAK,CAAC,kBAAkB,CAAC,CAAC;QACxC,CAAC;IACL,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,uGAAuG;AAEvG,4DAA4D;AAC5D,SAAgB,MAAM,CAAC,OAAkB;IACrC,MAAM,EAAE,GAAG,QAAQ,CAAC,GAAG,CAAE,OAAkB,CAAC,WAAW,CAAC,CAAC;IACzD,IAAI,EAAE,KAAK,SAAS,EAAE,CAAC;QACnB,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;IAC/E,CAAC;IACD,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;IACvD,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,GAAG,YAAY,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;AAC5G,CAAC;AAYD;;;GAGG;AACH,SAAgB,MAAM,CAAC,MAAc;IACjC,MAAM,MAAM,GAAmB,EAAE,CAAC;IAClC,IAAI,MAAM,GAAG,CAAC,CAAC;IAEf,OAAO,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;QAC5B,IAAI,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,KAAK,CACX,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC;gBAChB,CAAC,CAAC,8EAA8E;gBAChF,CAAC,CAAC,wBAAwB,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAC9D,CAAC;QACN,CAAC;QACD,MAAM,MAAM,GAAG,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,CAAC,CAAC,CAAC;QAC9C,IAAI,CAAC,MAAM,EAAE,CAAC;YACV,MAAM;QACV,CAAC;QACD,MAAM,EAAE,GAAG,UAAU,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;QAC7C,IAAI,CAAC,EAAE,EAAE,CAAC;YACN,MAAM;QACV,CAAC;QACD,MAAM,GAAG,GAAG,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC;QACrC,IAAI,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;YACtB,MAAM,CAAC,+BAA+B;QAC1C,CAAC;QAED,MAAM,OAAO,GAAG,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChD,MAAM,IAAI,GAAG,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC;QAC5C,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC;QACrC,IAAI,OAAO,GAAY,IAAI,CAAC;QAC5B,IAAI,KAAK,GAAiB,IAAI,CAAC;QAC/B,IAAI,IAAI,EAAE,CAAC;YACP,IAAI,CAAC;gBACD,OAAO,GAAG,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;YAC9C,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACT,KAAK,GAAG,CAAU,CAAC;YACvB,CAAC;QACL,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;QACpD,MAAM,GAAG,GAAG,CAAC;IACjB,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;AACrD,CAAC","sourcesContent":["/**\n * ESPHome native-API wire codec (plain-text framing) — the transport half of `esphome.ts`.\n *\n * Frame layout: `0x00 | varint(payload length) | varint(message id) | payload (protobuf)`.\n *\n * Only the generated protobuf classes of `@2colors/esphome-native-api` are reused, not its\n * `Connection`/`FrameHelper`: that package's id↔type table stops at the Bluetooth range and omits the\n * whole VoiceAssistant block (89–92, 106, 115, 119–123). Worse, an unknown id makes its frame helper\n * throw *without advancing the read buffer*, so the connection stalls instead of skipping the message.\n * The registry here is derived from the `api.proto` the package ships, which covers every message the\n * device can send, and unknown ids are skipped by their declared length.\n *\n * The device speaks plain text (no noise encryption) — confirmed in the firmware's\n * `linux-voice-assistant-cpp` README (\"ESPHome native API server (TCP 6053, plain-text framing,\n * protobuf)\") — so the encrypted variant is deliberately not implemented; `decode` reports it clearly\n * if a device ever shows up with encryption switched on.\n */\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\n// ── minimal structural typing over the generated protobuf classes ───────────────────────────────────\n\nexport interface PbMessage {\n serializeBinary(): Uint8Array;\n}\n\n/** Messages we build and send (client → device). */\nexport interface HelloRequest extends PbMessage {\n setClientInfo(value: string): void;\n setApiVersionMajor(value: number): void;\n setApiVersionMinor(value: number): void;\n}\nexport interface AuthenticationRequest extends PbMessage {\n setPassword(value: string): void;\n}\nexport interface SubscribeVoiceAssistantRequest extends PbMessage {\n setSubscribe(value: boolean): void;\n setFlags(value: number): void;\n}\nexport interface VoiceAssistantResponseMsg extends PbMessage {\n setPort(value: number): void;\n setError(value: boolean): void;\n}\nexport interface VoiceAssistantEventData extends PbMessage {\n setName(value: string): void;\n setValue(value: string): void;\n}\nexport interface VoiceAssistantEventResponse extends PbMessage {\n setEventType(value: number): void;\n setDataList(value: VoiceAssistantEventData[]): void;\n}\nexport interface VoiceAssistantAnnounceRequest extends PbMessage {\n setMediaId(value: string): void;\n setText(value: string): void;\n setPreannounceMediaId(value: string): void;\n setStartConversation(value: boolean): void;\n}\nexport interface GetTimeResponse extends PbMessage {\n setEpochSeconds(value: number): void;\n}\n/** Selects which of the device's wake words are listening; the device persists this itself. */\nexport interface VoiceAssistantSetConfiguration extends PbMessage {\n setActiveWakeWordsList(value: string[]): void;\n}\n/** A model the device may download when it is selected (custom wake words). */\nexport interface VoiceAssistantExternalWakeWord extends PbMessage {\n setId(value: string): void;\n setWakeWord(value: string): void;\n setTrainedLanguagesList(value: string[]): void;\n setModelType(value: string): void;\n setModelSize(value: number): void;\n setModelHash(value: string): void;\n setUrl(value: string): void;\n}\n/** Asks for the wake-word configuration — and offers the external ones, replacing the last offer. */\nexport interface VoiceAssistantConfigurationRequest extends PbMessage {\n setExternalWakeWordsList(value: VoiceAssistantExternalWakeWord[]): void;\n}\n\n// Entity command messages. All address an entity by the `fixed32 key` from its announcement.\nexport interface SwitchCommandRequest extends PbMessage {\n setKey(value: number): void;\n setState(value: boolean): void;\n}\nexport interface NumberCommandRequest extends PbMessage {\n setKey(value: number): void;\n setState(value: number): void;\n}\nexport interface SelectCommandRequest extends PbMessage {\n setKey(value: number): void;\n setState(value: string): void;\n}\nexport interface UpdateCommandRequest extends PbMessage {\n setKey(value: number): void;\n setCommand(value: number): void;\n}\nexport interface MediaPlayerCommandRequest extends PbMessage {\n setKey(value: number): void;\n setHasCommand(value: boolean): void;\n setCommand(value: number): void;\n setHasVolume(value: boolean): void;\n setVolume(value: number): void;\n}\n/** Mirrors an assistant timer onto the device, which drives its own LED ring and gong. */\nexport interface VoiceAssistantTimerEventResponse extends PbMessage {\n setEventType(value: number): void;\n setTimerId(value: string): void;\n setName(value: string): void;\n setTotalSeconds(value: number): void;\n setSecondsLeft(value: number): void;\n setIsActive(value: boolean): void;\n}\n\n/** Messages we receive (device → client). */\nexport interface HelloResponse {\n getApiVersionMajor(): number;\n getApiVersionMinor(): number;\n getServerInfo(): string;\n getName(): string;\n}\nexport interface AuthenticationResponse {\n getInvalidPassword(): boolean;\n}\nexport interface DeviceInfoResponse {\n getName(): string;\n /** What the device is called in the UI; entities without a name of their own are named after it. */\n getFriendlyName(): string;\n getModel(): string;\n getEsphomeVersion(): string;\n getMacAddress(): string;\n}\nexport interface VoiceAssistantWakeWord {\n getId(): string;\n getWakeWord(): string;\n /** ISO-639-1 codes the model was trained on — only `okay_nabu` covers more than English. */\n getTrainedLanguagesList(): string[];\n}\nexport interface VoiceAssistantConfigurationResponse {\n getAvailableWakeWordsList(): VoiceAssistantWakeWord[];\n getActiveWakeWordsList(): string[];\n /** How many wake words may listen at once (2 on the ThirdReality speaker). */\n getMaxActiveWakeWords(): number;\n}\nexport interface VoiceAssistantRequestMsg {\n getStart(): boolean;\n getConversationId(): string;\n getWakeWordPhrase(): string;\n}\nexport interface VoiceAssistantAudio {\n getData_asU8(): Uint8Array;\n getEnd(): boolean;\n}\nexport interface VoiceAssistantAnnounceFinished {\n getSuccess(): boolean;\n}\n\ntype Ctor<T> = new () => T;\n\ninterface ApiPb {\n HelloRequest: Ctor<HelloRequest>;\n AuthenticationRequest: Ctor<AuthenticationRequest>;\n DeviceInfoRequest: Ctor<PbMessage>;\n ListEntitiesRequest: Ctor<PbMessage>;\n SubscribeStatesRequest: Ctor<PbMessage>;\n SubscribeVoiceAssistantRequest: Ctor<SubscribeVoiceAssistantRequest>;\n VoiceAssistantConfigurationRequest: Ctor<VoiceAssistantConfigurationRequest>;\n VoiceAssistantExternalWakeWord: Ctor<VoiceAssistantExternalWakeWord>;\n VoiceAssistantSetConfiguration: Ctor<VoiceAssistantSetConfiguration>;\n VoiceAssistantTimerEventResponse: Ctor<VoiceAssistantTimerEventResponse>;\n SwitchCommandRequest: Ctor<SwitchCommandRequest>;\n NumberCommandRequest: Ctor<NumberCommandRequest>;\n SelectCommandRequest: Ctor<SelectCommandRequest>;\n UpdateCommandRequest: Ctor<UpdateCommandRequest>;\n MediaPlayerCommandRequest: Ctor<MediaPlayerCommandRequest>;\n VoiceAssistantResponse: Ctor<VoiceAssistantResponseMsg>;\n VoiceAssistantEventResponse: Ctor<VoiceAssistantEventResponse>;\n VoiceAssistantEventData: Ctor<VoiceAssistantEventData>;\n VoiceAssistantAnnounceRequest: Ctor<VoiceAssistantAnnounceRequest>;\n PingRequest: Ctor<PbMessage>;\n PingResponse: Ctor<PbMessage>;\n GetTimeResponse: Ctor<GetTimeResponse>;\n DisconnectRequest: Ctor<PbMessage>;\n DisconnectResponse: Ctor<PbMessage>;\n [name: string]: Ctor<unknown> | undefined;\n}\n\nconst API_PB_MODULE = '@2colors/esphome-native-api/lib/protoc/api_pb';\n\nexport const pb = require(API_PB_MODULE) as ApiPb;\n\n// ── message registry, built from the shipped api.proto ──────────────────────────────────────────────\n\n/** Deserialiser for one message id. */\ninterface Deserialiser {\n deserializeBinary(bytes: Uint8Array): unknown;\n}\n\nconst idToName = new Map<number, string>();\nconst classById = new Map<number, Deserialiser>();\n/** Message class → its wire id, so `encode` can find the id from an instance. */\nconst idByCtor = new Map<unknown, number>();\n\nfunction buildRegistry(): void {\n const protoFile = path.join(path.dirname(require.resolve(API_PB_MODULE)), 'api.proto');\n const source = fs.readFileSync(protoFile, 'utf8');\n let current: string | null = null;\n\n for (const raw of source.split(/\\r?\\n/)) {\n const line = raw.trim();\n const message = /^message\\s+(\\w+)\\s*\\{/.exec(line);\n if (message) {\n current = message[1];\n continue;\n }\n if (line === '}') {\n current = null;\n continue;\n }\n const option = /^option\\s*\\(\\s*id\\s*\\)\\s*=\\s*(\\d+)\\s*;/.exec(line);\n if (!option || !current) {\n continue;\n }\n const ctor = pb[current];\n if (!ctor) {\n continue; // in the .proto but not in the generated JS — nothing we can do with it\n }\n const id = Number(option[1]);\n idToName.set(id, current);\n classById.set(id, ctor as unknown as Deserialiser);\n idByCtor.set(ctor, id);\n }\n}\nbuildRegistry();\n\n/** Number of messages the registry knows (130 with the shipped api.proto) — used by the unit test. */\nexport const registrySize = idToName.size;\n\n// ── varint helpers ──────────────────────────────────────────────────────────────────────────────────\n\nfunction encodeVarint(value: number): number[] {\n const out: number[] = [];\n let rest = value >>> 0;\n do {\n let byte = rest & 0x7f;\n rest >>>= 7;\n if (rest) {\n byte |= 0x80;\n }\n out.push(byte);\n } while (rest);\n return out;\n}\n\n/** Read a varint at `offset`; null when the buffer holds only part of it (wait for more data). */\nfunction readVarint(buf: Buffer, offset: number): { value: number; offset: number } | null {\n let result = 0;\n let shift = 0;\n let pos = offset;\n while (pos < buf.length) {\n const byte = buf[pos++];\n result |= (byte & 0x7f) << shift;\n if (!(byte & 0x80)) {\n return { value: result >>> 0, offset: pos };\n }\n shift += 7;\n if (shift > 28) {\n throw new Error('malformed varint');\n }\n }\n return null;\n}\n\n// ── framing ─────────────────────────────────────────────────────────────────────────────────────────\n\n/** Serialise one protobuf message into a complete frame. */\nexport function encode(message: PbMessage): Buffer {\n const id = idByCtor.get((message as object).constructor);\n if (id === undefined) {\n throw new Error('message class is not registered in the ESPHome id table');\n }\n const payload = Buffer.from(message.serializeBinary());\n return Buffer.concat([Buffer.from([0, ...encodeVarint(payload.length), ...encodeVarint(id)]), payload]);\n}\n\nexport interface DecodedFrame {\n id: number;\n /** null for a message id the registry doesn't know — the frame was skipped by its declared length. */\n name: string | null;\n /** The decoded message, or null when the id is unknown or the payload didn't parse. */\n message: unknown;\n /** Set when a known message failed to deserialise. */\n error: Error | null;\n}\n\n/**\n * Pull every complete frame out of `buffer`. Returns the frames plus the unconsumed tail; the input is\n * never mutated, so the caller just keeps assigning `rest` back to its receive buffer.\n */\nexport function decode(buffer: Buffer): { frames: DecodedFrame[]; rest: Buffer } {\n const frames: DecodedFrame[] = [];\n let offset = 0;\n\n while (offset < buffer.length) {\n if (buffer[offset] !== 0) {\n throw new Error(\n buffer[offset] === 1\n ? 'device uses the encrypted (noise) ESPHome API — only plain text is supported'\n : `bad frame preamble 0x${buffer[offset].toString(16)}`,\n );\n }\n const length = readVarint(buffer, offset + 1);\n if (!length) {\n break;\n }\n const id = readVarint(buffer, length.offset);\n if (!id) {\n break;\n }\n const end = id.offset + length.value;\n if (end > buffer.length) {\n break; // frame not fully received yet\n }\n\n const payload = buffer.subarray(id.offset, end);\n const name = idToName.get(id.value) ?? null;\n const ctor = classById.get(id.value);\n let message: unknown = null;\n let error: Error | null = null;\n if (ctor) {\n try {\n message = ctor.deserializeBinary(payload);\n } catch (e) {\n error = e as Error;\n }\n }\n frames.push({ id: id.value, name, message, error });\n offset = end;\n }\n\n return { frames, rest: buffer.subarray(offset) };\n}\n"]}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.encodeFlac = encodeFlac;
|
|
4
|
+
/**
|
|
5
|
+
* A minimal FLAC encoder for the spoken replies of ESPHome satellites.
|
|
6
|
+
*
|
|
7
|
+
* Since ESPHome 2026.10 the media player of a satellite is built only with the decoders its own pipelines use —
|
|
8
|
+
* typically FLAC (what Home Assistant transcodes to) and MP3 (the wake sound). A WAV reply then does not play at
|
|
9
|
+
* all. FLAC can be written without a codec library: every subframe here is VERBATIM, i.e. the samples as they are.
|
|
10
|
+
* That gives files as large as a WAV — irrelevant on the LAN between the adapter and the speaker — but a stream every
|
|
11
|
+
* FLAC decoder must read, and the cheapest one for the ESP to decode.
|
|
12
|
+
*
|
|
13
|
+
* Layout (https://www.rfc-editor.org/rfc/rfc9639): `fLaC`, one STREAMINFO block, then frames of up to
|
|
14
|
+
* {@link BLOCK_SIZE} samples per channel, each with header + CRC-8, one subframe per channel, and a CRC-16.
|
|
15
|
+
*/
|
|
16
|
+
const node_crypto_1 = require("node:crypto");
|
|
17
|
+
/** Samples per channel in one frame — the common libFLAC default. */
|
|
18
|
+
const BLOCK_SIZE = 4096;
|
|
19
|
+
/** FLAC forbids blocks below 16 samples (except as the last one); a shorter clip is padded with silence. */
|
|
20
|
+
const MIN_BLOCK = 16;
|
|
21
|
+
/** Frame-header sample-rate codes (RFC 9639 9.1.2). Others are written out after the header. */
|
|
22
|
+
const RATE_CODES = {
|
|
23
|
+
88200: 1,
|
|
24
|
+
176400: 2,
|
|
25
|
+
192000: 3,
|
|
26
|
+
8000: 4,
|
|
27
|
+
16000: 5,
|
|
28
|
+
22050: 6,
|
|
29
|
+
24000: 7,
|
|
30
|
+
32000: 8,
|
|
31
|
+
44100: 9,
|
|
32
|
+
48000: 10,
|
|
33
|
+
96000: 11,
|
|
34
|
+
};
|
|
35
|
+
const CRC8 = new Uint8Array(256);
|
|
36
|
+
const CRC16 = new Uint16Array(256);
|
|
37
|
+
for (let i = 0; i < 256; i++) {
|
|
38
|
+
let c8 = i;
|
|
39
|
+
let c16 = i << 8;
|
|
40
|
+
for (let b = 0; b < 8; b++) {
|
|
41
|
+
c8 = c8 & 0x80 ? ((c8 << 1) ^ 0x07) & 0xff : (c8 << 1) & 0xff;
|
|
42
|
+
c16 = c16 & 0x8000 ? ((c16 << 1) ^ 0x8005) & 0xffff : (c16 << 1) & 0xffff;
|
|
43
|
+
}
|
|
44
|
+
CRC8[i] = c8;
|
|
45
|
+
CRC16[i] = c16;
|
|
46
|
+
}
|
|
47
|
+
function crc8(buf, end) {
|
|
48
|
+
let crc = 0;
|
|
49
|
+
for (let i = 0; i < end; i++) {
|
|
50
|
+
crc = CRC8[crc ^ buf[i]];
|
|
51
|
+
}
|
|
52
|
+
return crc;
|
|
53
|
+
}
|
|
54
|
+
function crc16(buf, end) {
|
|
55
|
+
let crc = 0;
|
|
56
|
+
for (let i = 0; i < end; i++) {
|
|
57
|
+
crc = ((crc << 8) & 0xffff) ^ CRC16[(crc >> 8) ^ buf[i]];
|
|
58
|
+
}
|
|
59
|
+
return crc;
|
|
60
|
+
}
|
|
61
|
+
/** The frame number in FLAC's UTF-8-like coding (up to 31 bits). */
|
|
62
|
+
function codedNumber(n) {
|
|
63
|
+
if (n < 0x80) {
|
|
64
|
+
return [n];
|
|
65
|
+
}
|
|
66
|
+
const bytes = [];
|
|
67
|
+
let rest = n;
|
|
68
|
+
// Continuation bytes carry 6 bits each; the lead byte has room for (6 - count) bits.
|
|
69
|
+
while (rest >= 1 << (6 - bytes.length)) {
|
|
70
|
+
bytes.unshift(0x80 | (rest & 0x3f));
|
|
71
|
+
rest = Math.floor(rest / 64);
|
|
72
|
+
}
|
|
73
|
+
const count = bytes.length + 1;
|
|
74
|
+
const lead = (0xff << (8 - count)) & 0xff;
|
|
75
|
+
return [lead | rest, ...bytes];
|
|
76
|
+
}
|
|
77
|
+
/** Rate code for the frame header, plus the bytes that follow the header when the rate is not one of the codes. */
|
|
78
|
+
function rateField(sampleRate) {
|
|
79
|
+
const code = RATE_CODES[sampleRate];
|
|
80
|
+
if (code !== undefined) {
|
|
81
|
+
return { code, extra: [] };
|
|
82
|
+
}
|
|
83
|
+
if (sampleRate <= 0xffff) {
|
|
84
|
+
return { code: 13, extra: [sampleRate >> 8, sampleRate & 0xff] }; // 16-bit Hz
|
|
85
|
+
}
|
|
86
|
+
if (sampleRate % 10 === 0 && sampleRate / 10 <= 0xffff) {
|
|
87
|
+
const tens = sampleRate / 10;
|
|
88
|
+
return { code: 14, extra: [tens >> 8, tens & 0xff] }; // 16-bit tens of Hz
|
|
89
|
+
}
|
|
90
|
+
return { code: 0, extra: [] }; // see STREAMINFO
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Encode 16-bit little-endian PCM (interleaved when `channels` is 2) as a FLAC file.
|
|
94
|
+
*
|
|
95
|
+
* @param pcm s16le samples
|
|
96
|
+
* @param sampleRate in Hz (1…655350)
|
|
97
|
+
* @param channels 1 or 2
|
|
98
|
+
*/
|
|
99
|
+
function encodeFlac(pcm, sampleRate, channels = 1) {
|
|
100
|
+
if (channels < 1 || channels > 8 || !Number.isInteger(channels)) {
|
|
101
|
+
throw new Error(`FLAC: ${channels} channels are not supported`);
|
|
102
|
+
}
|
|
103
|
+
if (!Number.isInteger(sampleRate) || sampleRate < 1 || sampleRate > 655350) {
|
|
104
|
+
throw new Error(`FLAC: sample rate ${sampleRate} is not supported`);
|
|
105
|
+
}
|
|
106
|
+
const frameBytes = 2 * channels;
|
|
107
|
+
let samples = Math.floor(pcm.length / frameBytes);
|
|
108
|
+
let data = pcm.subarray(0, samples * frameBytes);
|
|
109
|
+
if (samples < MIN_BLOCK) {
|
|
110
|
+
data = Buffer.concat([data, Buffer.alloc((MIN_BLOCK - samples) * frameBytes)]);
|
|
111
|
+
samples = MIN_BLOCK;
|
|
112
|
+
}
|
|
113
|
+
const rate = rateField(sampleRate);
|
|
114
|
+
const frames = [];
|
|
115
|
+
let minFrame = Number.MAX_SAFE_INTEGER;
|
|
116
|
+
let maxFrame = 0;
|
|
117
|
+
for (let start = 0, index = 0; start < samples; start += BLOCK_SIZE, index++) {
|
|
118
|
+
const count = Math.min(BLOCK_SIZE, samples - start);
|
|
119
|
+
const header = [
|
|
120
|
+
0xff,
|
|
121
|
+
0xf8, // sync code, fixed block size
|
|
122
|
+
(0b0111 << 4) | rate.code, // block size: 16 bit (count − 1) after the coded number
|
|
123
|
+
((channels - 1) << 4) | (0b100 << 1), // independent channels, 16 bits per sample
|
|
124
|
+
...codedNumber(index),
|
|
125
|
+
(count - 1) >> 8,
|
|
126
|
+
(count - 1) & 0xff,
|
|
127
|
+
...rate.extra,
|
|
128
|
+
];
|
|
129
|
+
const size = header.length + 1 + channels * (1 + count * 2) + 2;
|
|
130
|
+
const frame = Buffer.alloc(size);
|
|
131
|
+
frame.set(header, 0);
|
|
132
|
+
let pos = header.length;
|
|
133
|
+
frame[pos] = crc8(frame, pos);
|
|
134
|
+
pos++;
|
|
135
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
136
|
+
frame[pos++] = 0x02; // subframe header: VERBATIM, no wasted bits
|
|
137
|
+
for (let i = 0; i < count; i++) {
|
|
138
|
+
frame.writeInt16BE(data.readInt16LE(((start + i) * channels + ch) * 2), pos);
|
|
139
|
+
pos += 2;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
frame.writeUInt16BE(crc16(frame, pos), pos);
|
|
143
|
+
frames.push(frame);
|
|
144
|
+
minFrame = Math.min(minFrame, size);
|
|
145
|
+
maxFrame = Math.max(maxFrame, size);
|
|
146
|
+
}
|
|
147
|
+
const block = Math.min(BLOCK_SIZE, samples);
|
|
148
|
+
const info = Buffer.alloc(4 + 34);
|
|
149
|
+
info[0] = 0x80; // last metadata block, type 0 = STREAMINFO
|
|
150
|
+
info.writeUIntBE(34, 1, 3);
|
|
151
|
+
info.writeUInt16BE(block, 4); // min block size (the last block may be shorter)
|
|
152
|
+
info.writeUInt16BE(block, 6); // max block size
|
|
153
|
+
info.writeUIntBE(minFrame, 8, 3);
|
|
154
|
+
info.writeUIntBE(maxFrame, 11, 3);
|
|
155
|
+
// 20 bits sample rate, 3 bits channels − 1, 5 bits bits-per-sample − 1, 36 bits total samples
|
|
156
|
+
const packed = (BigInt(sampleRate) << 44n) | (BigInt(channels - 1) << 41n) | (BigInt(16 - 1) << 36n) | BigInt(samples);
|
|
157
|
+
info.writeBigUInt64BE(packed, 14);
|
|
158
|
+
// MD5 of the decoded audio: the samples as interleaved little-endian 16-bit words — exactly `data`.
|
|
159
|
+
(0, node_crypto_1.createHash)('md5').update(data).digest().copy(info, 22);
|
|
160
|
+
return Buffer.concat([Buffer.from('fLaC', 'latin1'), info, ...frames]);
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=flac.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flac.js","sourceRoot":"","sources":["../../../src/lib/voice/flac.ts"],"names":[],"mappings":";;AAuGA,gCAkEC;AAzKD;;;;;;;;;;;GAWG;AACH,6CAAyC;AAEzC,qEAAqE;AACrE,MAAM,UAAU,GAAG,IAAI,CAAC;AACxB,4GAA4G;AAC5G,MAAM,SAAS,GAAG,EAAE,CAAC;AAErB,gGAAgG;AAChG,MAAM,UAAU,GAA2B;IACvC,KAAK,EAAE,CAAC;IACR,MAAM,EAAE,CAAC;IACT,MAAM,EAAE,CAAC;IACT,IAAI,EAAE,CAAC;IACP,KAAK,EAAE,CAAC;IACR,KAAK,EAAE,CAAC;IACR,KAAK,EAAE,CAAC;IACR,KAAK,EAAE,CAAC;IACR,KAAK,EAAE,CAAC;IACR,KAAK,EAAE,EAAE;IACT,KAAK,EAAE,EAAE;CACZ,CAAC;AAEF,MAAM,IAAI,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,CAAC;AACjC,MAAM,KAAK,GAAG,IAAI,WAAW,CAAC,GAAG,CAAC,CAAC;AACnC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;IAC3B,IAAI,EAAE,GAAG,CAAC,CAAC;IACX,IAAI,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC;IACjB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QACzB,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC;QAC9D,GAAG,GAAG,GAAG,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,MAAM,CAAC;IAC9E,CAAC;IACD,IAAI,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;IACb,KAAK,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC;AACnB,CAAC;AAED,SAAS,IAAI,CAAC,GAAe,EAAE,GAAW;IACtC,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAC3B,GAAG,GAAG,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7B,CAAC;IACD,OAAO,GAAG,CAAC;AACf,CAAC;AAED,SAAS,KAAK,CAAC,GAAe,EAAE,GAAW;IACvC,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAC3B,GAAG,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7D,CAAC;IACD,OAAO,GAAG,CAAC;AACf,CAAC;AAED,oEAAoE;AACpE,SAAS,WAAW,CAAC,CAAS;IAC1B,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC;QACX,OAAO,CAAC,CAAC,CAAC,CAAC;IACf,CAAC;IACD,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,qFAAqF;IACrF,OAAO,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;QACrC,KAAK,CAAC,OAAO,CAAC,IAAI,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC;QACpC,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,EAAE,CAAC,CAAC;IACjC,CAAC;IACD,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;IAC/B,MAAM,IAAI,GAAG,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,IAAI,CAAC;IAC1C,OAAO,CAAC,IAAI,GAAG,IAAI,EAAE,GAAG,KAAK,CAAC,CAAC;AACnC,CAAC;AAED,mHAAmH;AACnH,SAAS,SAAS,CAAC,UAAkB;IACjC,MAAM,IAAI,GAAG,UAAU,CAAC,UAAU,CAAC,CAAC;IACpC,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACrB,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;IAC/B,CAAC;IACD,IAAI,UAAU,IAAI,MAAM,EAAE,CAAC;QACvB,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,UAAU,IAAI,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,YAAY;IAClF,CAAC;IACD,IAAI,UAAU,GAAG,EAAE,KAAK,CAAC,IAAI,UAAU,GAAG,EAAE,IAAI,MAAM,EAAE,CAAC;QACrD,MAAM,IAAI,GAAG,UAAU,GAAG,EAAE,CAAC;QAC7B,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,IAAI,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,oBAAoB;IAC9E,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,CAAC,iBAAiB;AACpD,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,UAAU,CAAC,GAAW,EAAE,UAAkB,EAAE,QAAQ,GAAG,CAAC;IACpE,IAAI,QAAQ,GAAG,CAAC,IAAI,QAAQ,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC9D,MAAM,IAAI,KAAK,CAAC,SAAS,QAAQ,6BAA6B,CAAC,CAAC;IACpE,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,IAAI,UAAU,GAAG,CAAC,IAAI,UAAU,GAAG,MAAM,EAAE,CAAC;QACzE,MAAM,IAAI,KAAK,CAAC,qBAAqB,UAAU,mBAAmB,CAAC,CAAC;IACxE,CAAC;IACD,MAAM,UAAU,GAAG,CAAC,GAAG,QAAQ,CAAC;IAChC,IAAI,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,GAAG,UAAU,CAAC,CAAC;IAClD,IAAI,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC,CAAC;IACjD,IAAI,OAAO,GAAG,SAAS,EAAE,CAAC;QACtB,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,GAAG,OAAO,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC;QAC/E,OAAO,GAAG,SAAS,CAAC;IACxB,CAAC;IAED,MAAM,IAAI,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC;IACnC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,QAAQ,GAAG,MAAM,CAAC,gBAAgB,CAAC;IACvC,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,OAAO,EAAE,KAAK,IAAI,UAAU,EAAE,KAAK,EAAE,EAAE,CAAC;QAC3E,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,OAAO,GAAG,KAAK,CAAC,CAAC;QACpD,MAAM,MAAM,GAAG;YACX,IAAI;YACJ,IAAI,EAAE,8BAA8B;YACpC,CAAC,MAAM,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,IAAI,EAAE,wDAAwD;YACnF,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,2CAA2C;YACjF,GAAG,WAAW,CAAC,KAAK,CAAC;YACrB,CAAC,KAAK,GAAG,CAAC,CAAC,IAAI,CAAC;YAChB,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,IAAI;YAClB,GAAG,IAAI,CAAC,KAAK;SAChB,CAAC;QACF,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAC,GAAG,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;QAChE,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACjC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACrB,IAAI,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC;QACxB,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QAC9B,GAAG,EAAE,CAAC;QACN,KAAK,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,QAAQ,EAAE,EAAE,EAAE,EAAE,CAAC;YACnC,KAAK,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,4CAA4C;YACjE,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC;gBAC7B,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,QAAQ,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;gBAC7E,GAAG,IAAI,CAAC,CAAC;YACb,CAAC;QACL,CAAC;QACD,KAAK,CAAC,aAAa,CAAC,KAAK,CAAC,KAAK,EAAE,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;QAC5C,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACnB,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QACpC,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IACxC,CAAC;IAED,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC5C,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;IAClC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,2CAA2C;IAC3D,IAAI,CAAC,WAAW,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IAC3B,IAAI,CAAC,aAAa,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,iDAAiD;IAC/E,IAAI,CAAC,aAAa,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,iBAAiB;IAC/C,IAAI,CAAC,WAAW,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IACjC,IAAI,CAAC,WAAW,CAAC,QAAQ,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;IAClC,8FAA8F;IAC9F,MAAM,MAAM,GACR,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;IAC5G,IAAI,CAAC,gBAAgB,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAClC,oGAAoG;IACpG,IAAA,wBAAU,EAAC,KAAK,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;IAEvD,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC,CAAC,CAAC;AAC3E,CAAC","sourcesContent":["/**\n * A minimal FLAC encoder for the spoken replies of ESPHome satellites.\n *\n * Since ESPHome 2026.10 the media player of a satellite is built only with the decoders its own pipelines use —\n * typically FLAC (what Home Assistant transcodes to) and MP3 (the wake sound). A WAV reply then does not play at\n * all. FLAC can be written without a codec library: every subframe here is VERBATIM, i.e. the samples as they are.\n * That gives files as large as a WAV — irrelevant on the LAN between the adapter and the speaker — but a stream every\n * FLAC decoder must read, and the cheapest one for the ESP to decode.\n *\n * Layout (https://www.rfc-editor.org/rfc/rfc9639): `fLaC`, one STREAMINFO block, then frames of up to\n * {@link BLOCK_SIZE} samples per channel, each with header + CRC-8, one subframe per channel, and a CRC-16.\n */\nimport { createHash } from 'node:crypto';\n\n/** Samples per channel in one frame — the common libFLAC default. */\nconst BLOCK_SIZE = 4096;\n/** FLAC forbids blocks below 16 samples (except as the last one); a shorter clip is padded with silence. */\nconst MIN_BLOCK = 16;\n\n/** Frame-header sample-rate codes (RFC 9639 9.1.2). Others are written out after the header. */\nconst RATE_CODES: Record<number, number> = {\n 88200: 1,\n 176400: 2,\n 192000: 3,\n 8000: 4,\n 16000: 5,\n 22050: 6,\n 24000: 7,\n 32000: 8,\n 44100: 9,\n 48000: 10,\n 96000: 11,\n};\n\nconst CRC8 = new Uint8Array(256);\nconst CRC16 = new Uint16Array(256);\nfor (let i = 0; i < 256; i++) {\n let c8 = i;\n let c16 = i << 8;\n for (let b = 0; b < 8; b++) {\n c8 = c8 & 0x80 ? ((c8 << 1) ^ 0x07) & 0xff : (c8 << 1) & 0xff;\n c16 = c16 & 0x8000 ? ((c16 << 1) ^ 0x8005) & 0xffff : (c16 << 1) & 0xffff;\n }\n CRC8[i] = c8;\n CRC16[i] = c16;\n}\n\nfunction crc8(buf: Uint8Array, end: number): number {\n let crc = 0;\n for (let i = 0; i < end; i++) {\n crc = CRC8[crc ^ buf[i]];\n }\n return crc;\n}\n\nfunction crc16(buf: Uint8Array, end: number): number {\n let crc = 0;\n for (let i = 0; i < end; i++) {\n crc = ((crc << 8) & 0xffff) ^ CRC16[(crc >> 8) ^ buf[i]];\n }\n return crc;\n}\n\n/** The frame number in FLAC's UTF-8-like coding (up to 31 bits). */\nfunction codedNumber(n: number): number[] {\n if (n < 0x80) {\n return [n];\n }\n const bytes: number[] = [];\n let rest = n;\n // Continuation bytes carry 6 bits each; the lead byte has room for (6 - count) bits.\n while (rest >= 1 << (6 - bytes.length)) {\n bytes.unshift(0x80 | (rest & 0x3f));\n rest = Math.floor(rest / 64);\n }\n const count = bytes.length + 1;\n const lead = (0xff << (8 - count)) & 0xff;\n return [lead | rest, ...bytes];\n}\n\n/** Rate code for the frame header, plus the bytes that follow the header when the rate is not one of the codes. */\nfunction rateField(sampleRate: number): { code: number; extra: number[] } {\n const code = RATE_CODES[sampleRate];\n if (code !== undefined) {\n return { code, extra: [] };\n }\n if (sampleRate <= 0xffff) {\n return { code: 13, extra: [sampleRate >> 8, sampleRate & 0xff] }; // 16-bit Hz\n }\n if (sampleRate % 10 === 0 && sampleRate / 10 <= 0xffff) {\n const tens = sampleRate / 10;\n return { code: 14, extra: [tens >> 8, tens & 0xff] }; // 16-bit tens of Hz\n }\n return { code: 0, extra: [] }; // see STREAMINFO\n}\n\n/**\n * Encode 16-bit little-endian PCM (interleaved when `channels` is 2) as a FLAC file.\n *\n * @param pcm s16le samples\n * @param sampleRate in Hz (1…655350)\n * @param channels 1 or 2\n */\nexport function encodeFlac(pcm: Buffer, sampleRate: number, channels = 1): Buffer {\n if (channels < 1 || channels > 8 || !Number.isInteger(channels)) {\n throw new Error(`FLAC: ${channels} channels are not supported`);\n }\n if (!Number.isInteger(sampleRate) || sampleRate < 1 || sampleRate > 655350) {\n throw new Error(`FLAC: sample rate ${sampleRate} is not supported`);\n }\n const frameBytes = 2 * channels;\n let samples = Math.floor(pcm.length / frameBytes);\n let data = pcm.subarray(0, samples * frameBytes);\n if (samples < MIN_BLOCK) {\n data = Buffer.concat([data, Buffer.alloc((MIN_BLOCK - samples) * frameBytes)]);\n samples = MIN_BLOCK;\n }\n\n const rate = rateField(sampleRate);\n const frames: Buffer[] = [];\n let minFrame = Number.MAX_SAFE_INTEGER;\n let maxFrame = 0;\n for (let start = 0, index = 0; start < samples; start += BLOCK_SIZE, index++) {\n const count = Math.min(BLOCK_SIZE, samples - start);\n const header = [\n 0xff,\n 0xf8, // sync code, fixed block size\n (0b0111 << 4) | rate.code, // block size: 16 bit (count − 1) after the coded number\n ((channels - 1) << 4) | (0b100 << 1), // independent channels, 16 bits per sample\n ...codedNumber(index),\n (count - 1) >> 8,\n (count - 1) & 0xff,\n ...rate.extra,\n ];\n const size = header.length + 1 + channels * (1 + count * 2) + 2;\n const frame = Buffer.alloc(size);\n frame.set(header, 0);\n let pos = header.length;\n frame[pos] = crc8(frame, pos);\n pos++;\n for (let ch = 0; ch < channels; ch++) {\n frame[pos++] = 0x02; // subframe header: VERBATIM, no wasted bits\n for (let i = 0; i < count; i++) {\n frame.writeInt16BE(data.readInt16LE(((start + i) * channels + ch) * 2), pos);\n pos += 2;\n }\n }\n frame.writeUInt16BE(crc16(frame, pos), pos);\n frames.push(frame);\n minFrame = Math.min(minFrame, size);\n maxFrame = Math.max(maxFrame, size);\n }\n\n const block = Math.min(BLOCK_SIZE, samples);\n const info = Buffer.alloc(4 + 34);\n info[0] = 0x80; // last metadata block, type 0 = STREAMINFO\n info.writeUIntBE(34, 1, 3);\n info.writeUInt16BE(block, 4); // min block size (the last block may be shorter)\n info.writeUInt16BE(block, 6); // max block size\n info.writeUIntBE(minFrame, 8, 3);\n info.writeUIntBE(maxFrame, 11, 3);\n // 20 bits sample rate, 3 bits channels − 1, 5 bits bits-per-sample − 1, 36 bits total samples\n const packed =\n (BigInt(sampleRate) << 44n) | (BigInt(channels - 1) << 41n) | (BigInt(16 - 1) << 36n) | BigInt(samples);\n info.writeBigUInt64BE(packed, 14);\n // MD5 of the decoded audio: the samples as interleaved little-endian 16-bit words — exactly `data`.\n createHash('md5').update(data).digest().copy(info, 22);\n\n return Buffer.concat([Buffer.from('fLaC', 'latin1'), info, ...frames]);\n}\n"]}
|
|
@@ -121,7 +121,14 @@ class MediaServer {
|
|
|
121
121
|
this.clips.delete(oldest.value);
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
-
|
|
124
|
+
// A device without a usable Content-Type check goes by the extension (ESPHome `detect_audio_file_type`).
|
|
125
|
+
const extension = contentType.startsWith('audio/mpeg')
|
|
126
|
+
? 'mp3'
|
|
127
|
+
: contentType.startsWith('audio/flac')
|
|
128
|
+
? 'flac'
|
|
129
|
+
: contentType.startsWith('audio/ogg')
|
|
130
|
+
? 'opus'
|
|
131
|
+
: 'wav';
|
|
125
132
|
const token = `${(0, node_crypto_1.randomBytes)(12).toString('hex')}.${extension}`;
|
|
126
133
|
this.clips.set(token, { body, contentType, expiresAt: Date.now() + ttlMs });
|
|
127
134
|
return `/media/${token}`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mediaServer.js","sourceRoot":"","sources":["../../../src/lib/voice/mediaServer.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Tiny HTTP server that hands out short-lived audio clips.\n *\n * ESPHome voice satellites do not receive the spoken reply over their API socket: the server puts a\n * **URL** into the `tts-end` event (and into `VoiceAssistantAnnounceRequest.media_id`), and the device\n * fetches and plays it with mpv. So the adapter has to be an HTTP origin for as long as a clip is\n * needed — that is all this is.\n *\n * Clips live in memory, are addressed by an unguessable token and expire on their own, so nothing is\n * written to disk and a clip cannot be replayed indefinitely by anyone who once saw the URL.\n *\n * Besides the clips it serves a few **fixed files** under a path the caller picks — the custom wake-word\n * models, which a device may fetch at any time after it was offered one. Only paths registered with\n * {@link MediaServer.serve} answer, so nothing beyond what was put there is reachable.\n */\nimport * as http from 'node:http';\nimport { randomBytes } from 'node:crypto';\n\ninterface Clip {\n body: Buffer;\n contentType: string;\n expiresAt: number;\n}\n\nexport interface MediaServerOptions {\n port: number;\n /** Interface to bind ('0.0.0.0'/'' = all). */\n bindAddress?: string;\n log: ioBroker.Logger;\n}\n\n/** How long a published clip stays fetchable. Generous: a device may buffer before it starts playing. */\nconst DEFAULT_TTL_MS = 120_000;\n/** Safety net against a leak if a device never fetches what we published. */\nconst MAX_CLIPS = 64;\n\nexport class MediaServer {\n private server: http.Server | null = null;\n private readonly clips = new Map<string, Clip>();\n /** Fixed files by exact path (`/wakeword/<id>.tflite`), served until removed. */\n private readonly files = new Map<string, { body: Buffer; contentType: string }>();\n private sweepTimer: NodeJS.Timeout | null = null;\n /** Port the OS actually handed out — differs from the configured one when that is 0. */\n private boundPort = 0;\n\n constructor(private readonly opts: MediaServerOptions) {}\n\n /** Port the server listens on. Only meaningful after `start()` when port 0 was configured. */\n get port(): number {\n return this.boundPort || this.opts.port;\n }\n\n start(): Promise<void> {\n return new Promise((resolve, reject) => {\n const server = http.createServer((req, res) => this.onRequest(req, res));\n this.server = server;\n server.once('error', reject);\n server.on('error', e => this.opts.log.error(`media server error: ${e.message}`));\n const address =\n this.opts.bindAddress && this.opts.bindAddress !== '0.0.0.0' ? this.opts.bindAddress : undefined;\n const onListening = (): void => {\n const bound = server.address();\n this.boundPort = typeof bound === 'object' && bound ? bound.port : this.opts.port;\n this.opts.log.info(`Media server listening on http://${address || '0.0.0.0'}:${this.port}`);\n this.sweepTimer = setInterval(() => this.sweep(), 30_000);\n this.sweepTimer.unref();\n resolve();\n };\n if (address) {\n server.listen(this.opts.port, address, onListening);\n } else {\n server.listen(this.opts.port, onListening);\n }\n });\n }\n\n async stop(): Promise<void> {\n if (this.sweepTimer) {\n clearInterval(this.sweepTimer);\n this.sweepTimer = null;\n }\n this.clips.clear();\n const server = this.server;\n this.server = null;\n if (server) {\n server.closeAllConnections?.();\n await new Promise<void>(resolve => server.close(() => resolve()));\n }\n }\n\n /**\n * Publish a clip and return the path it is served under (e.g. `/media/ab12….wav`). The caller\n * prefixes the host it wants the device to call back on — which interface that is depends on the\n * device, so only the caller can know it.\n */\n publish(body: Buffer, contentType = 'audio/wav', ttlMs = DEFAULT_TTL_MS): string {\n if (this.clips.size >= MAX_CLIPS) {\n this.sweep();\n // Still full? Drop the oldest — insertion order is age order.\n const oldest = this.clips.keys().next();\n if (this.clips.size >= MAX_CLIPS && !oldest.done) {\n this.clips.delete(oldest.value);\n }\n }\n const extension = contentType === 'audio/mpeg' ? 'mp3' : 'wav';\n const token = `${randomBytes(12).toString('hex')}.${extension}`;\n this.clips.set(token, { body, contentType, expiresAt: Date.now() + ttlMs });\n return `/media/${token}`;\n }\n\n /**\n * Serve `body` at exactly `urlPath` (e.g. `/wakeword/hey_hannah_1a2b3c4d.tflite`) until\n * {@link unserve} removes it. Replaces whatever was there.\n */\n serve(urlPath: string, body: Buffer, contentType: string): void {\n this.files.set(urlPath, { body, contentType });\n }\n\n unserve(urlPath: string): void {\n this.files.delete(urlPath);\n }\n\n private onRequest(req: http.IncomingMessage, res: http.ServerResponse): void {\n const urlPath = (req.url || '').split('?')[0];\n const file = this.files.get(urlPath);\n if (file) {\n this.opts.log.debug(`media: ${req.method} ${urlPath} → ${file.body.length} bytes`);\n res.writeHead(200, {\n 'Content-Type': file.contentType,\n 'Content-Length': file.body.length,\n 'Cache-Control': 'no-cache',\n });\n res.end(req.method === 'HEAD' ? undefined : file.body);\n return;\n }\n\n const token = /^\\/media\\/([\\w.]+)$/.exec(urlPath)?.[1];\n const clip = token ? this.clips.get(token) : undefined;\n\n if (!clip || clip.expiresAt < Date.now()) {\n res.writeHead(404, { 'Content-Type': 'text/plain' });\n res.end('not found');\n return;\n }\n this.opts.log.debug(`media: ${req.method} ${req.url} → ${clip.body.length} bytes`);\n res.writeHead(200, {\n 'Content-Type': clip.contentType,\n 'Content-Length': clip.body.length,\n 'Cache-Control': 'no-store',\n // mpv asks for ranges on some builds; saying so up front avoids a needless second request.\n 'Accept-Ranges': 'none',\n });\n res.end(req.method === 'HEAD' ? undefined : clip.body);\n }\n\n private sweep(): void {\n const now = Date.now();\n for (const [token, clip] of this.clips) {\n if (clip.expiresAt < now) {\n this.clips.delete(token);\n }\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"mediaServer.js","sourceRoot":"","sources":["../../../src/lib/voice/mediaServer.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Tiny HTTP server that hands out short-lived audio clips.\n *\n * ESPHome voice satellites do not receive the spoken reply over their API socket: the server puts a\n * **URL** into the `tts-end` event (and into `VoiceAssistantAnnounceRequest.media_id`), and the device\n * fetches and plays it with mpv. So the adapter has to be an HTTP origin for as long as a clip is\n * needed — that is all this is.\n *\n * Clips live in memory, are addressed by an unguessable token and expire on their own, so nothing is\n * written to disk and a clip cannot be replayed indefinitely by anyone who once saw the URL.\n *\n * Besides the clips it serves a few **fixed files** under a path the caller picks — the custom wake-word\n * models, which a device may fetch at any time after it was offered one. Only paths registered with\n * {@link MediaServer.serve} answer, so nothing beyond what was put there is reachable.\n */\nimport * as http from 'node:http';\nimport { randomBytes } from 'node:crypto';\n\ninterface Clip {\n body: Buffer;\n contentType: string;\n expiresAt: number;\n}\n\nexport interface MediaServerOptions {\n port: number;\n /** Interface to bind ('0.0.0.0'/'' = all). */\n bindAddress?: string;\n log: ioBroker.Logger;\n}\n\n/** How long a published clip stays fetchable. Generous: a device may buffer before it starts playing. */\nconst DEFAULT_TTL_MS = 120_000;\n/** Safety net against a leak if a device never fetches what we published. */\nconst MAX_CLIPS = 64;\n\nexport class MediaServer {\n private server: http.Server | null = null;\n private readonly clips = new Map<string, Clip>();\n /** Fixed files by exact path (`/wakeword/<id>.tflite`), served until removed. */\n private readonly files = new Map<string, { body: Buffer; contentType: string }>();\n private sweepTimer: NodeJS.Timeout | null = null;\n /** Port the OS actually handed out — differs from the configured one when that is 0. */\n private boundPort = 0;\n\n constructor(private readonly opts: MediaServerOptions) {}\n\n /** Port the server listens on. Only meaningful after `start()` when port 0 was configured. */\n get port(): number {\n return this.boundPort || this.opts.port;\n }\n\n start(): Promise<void> {\n return new Promise((resolve, reject) => {\n const server = http.createServer((req, res) => this.onRequest(req, res));\n this.server = server;\n server.once('error', reject);\n server.on('error', e => this.opts.log.error(`media server error: ${e.message}`));\n const address =\n this.opts.bindAddress && this.opts.bindAddress !== '0.0.0.0' ? this.opts.bindAddress : undefined;\n const onListening = (): void => {\n const bound = server.address();\n this.boundPort = typeof bound === 'object' && bound ? bound.port : this.opts.port;\n this.opts.log.info(`Media server listening on http://${address || '0.0.0.0'}:${this.port}`);\n this.sweepTimer = setInterval(() => this.sweep(), 30_000);\n this.sweepTimer.unref();\n resolve();\n };\n if (address) {\n server.listen(this.opts.port, address, onListening);\n } else {\n server.listen(this.opts.port, onListening);\n }\n });\n }\n\n async stop(): Promise<void> {\n if (this.sweepTimer) {\n clearInterval(this.sweepTimer);\n this.sweepTimer = null;\n }\n this.clips.clear();\n const server = this.server;\n this.server = null;\n if (server) {\n server.closeAllConnections?.();\n await new Promise<void>(resolve => server.close(() => resolve()));\n }\n }\n\n /**\n * Publish a clip and return the path it is served under (e.g. `/media/ab12….wav`). The caller\n * prefixes the host it wants the device to call back on — which interface that is depends on the\n * device, so only the caller can know it.\n */\n publish(body: Buffer, contentType = 'audio/wav', ttlMs = DEFAULT_TTL_MS): string {\n if (this.clips.size >= MAX_CLIPS) {\n this.sweep();\n // Still full? Drop the oldest — insertion order is age order.\n const oldest = this.clips.keys().next();\n if (this.clips.size >= MAX_CLIPS && !oldest.done) {\n this.clips.delete(oldest.value);\n }\n }\n // A device without a usable Content-Type check goes by the extension (ESPHome `detect_audio_file_type`).\n const extension = contentType.startsWith('audio/mpeg')\n ? 'mp3'\n : contentType.startsWith('audio/flac')\n ? 'flac'\n : contentType.startsWith('audio/ogg')\n ? 'opus'\n : 'wav';\n const token = `${randomBytes(12).toString('hex')}.${extension}`;\n this.clips.set(token, { body, contentType, expiresAt: Date.now() + ttlMs });\n return `/media/${token}`;\n }\n\n /**\n * Serve `body` at exactly `urlPath` (e.g. `/wakeword/hey_hannah_1a2b3c4d.tflite`) until\n * {@link unserve} removes it. Replaces whatever was there.\n */\n serve(urlPath: string, body: Buffer, contentType: string): void {\n this.files.set(urlPath, { body, contentType });\n }\n\n unserve(urlPath: string): void {\n this.files.delete(urlPath);\n }\n\n private onRequest(req: http.IncomingMessage, res: http.ServerResponse): void {\n const urlPath = (req.url || '').split('?')[0];\n const file = this.files.get(urlPath);\n if (file) {\n this.opts.log.debug(`media: ${req.method} ${urlPath} → ${file.body.length} bytes`);\n res.writeHead(200, {\n 'Content-Type': file.contentType,\n 'Content-Length': file.body.length,\n 'Cache-Control': 'no-cache',\n });\n res.end(req.method === 'HEAD' ? undefined : file.body);\n return;\n }\n\n const token = /^\\/media\\/([\\w.]+)$/.exec(urlPath)?.[1];\n const clip = token ? this.clips.get(token) : undefined;\n\n if (!clip || clip.expiresAt < Date.now()) {\n res.writeHead(404, { 'Content-Type': 'text/plain' });\n res.end('not found');\n return;\n }\n this.opts.log.debug(`media: ${req.method} ${req.url} → ${clip.body.length} bytes`);\n res.writeHead(200, {\n 'Content-Type': clip.contentType,\n 'Content-Length': clip.body.length,\n 'Cache-Control': 'no-store',\n // mpv asks for ranges on some builds; saying so up front avoids a needless second request.\n 'Accept-Ranges': 'none',\n });\n res.end(req.method === 'HEAD' ? undefined : clip.body);\n }\n\n private sweep(): void {\n const now = Date.now();\n for (const [token, clip] of this.clips) {\n if (clip.expiresAt < now) {\n this.clips.delete(token);\n }\n }\n }\n}\n"]}
|