iobroker.assistant 1.4.3 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +11 -0
  2. package/admin/custom/.vite/manifest.json +1 -1
  3. package/admin/custom/assets/Components-BR57MZLJ.js +2 -0
  4. package/admin/custom/customComponents.js +1 -1
  5. package/admin/custom/i18n/de.json +3 -0
  6. package/admin/custom/i18n/en.json +3 -0
  7. package/admin/custom/i18n/es.json +3 -0
  8. package/admin/custom/i18n/fr.json +3 -0
  9. package/admin/custom/i18n/it.json +3 -0
  10. package/admin/custom/i18n/nl.json +3 -0
  11. package/admin/custom/i18n/pl.json +3 -0
  12. package/admin/custom/i18n/pt.json +3 -0
  13. package/admin/custom/i18n/ru.json +3 -0
  14. package/admin/custom/i18n/uk.json +3 -0
  15. package/admin/custom/i18n/zh-cn.json +3 -0
  16. package/admin/custom/index.html +1 -1
  17. package/admin/custom/mf-manifest.json +1 -1
  18. package/admin/custom/mf-stats.json +1 -1
  19. package/build/lib/voice/esphome.js +209 -23
  20. package/build/lib/voice/esphome.js.map +1 -1
  21. package/build/lib/voice/esphomeEntities.js +76 -6
  22. package/build/lib/voice/esphomeEntities.js.map +1 -1
  23. package/build/lib/voice/esphomeProto.js.map +1 -1
  24. package/build/lib/voice/flac.js +162 -0
  25. package/build/lib/voice/flac.js.map +1 -0
  26. package/build/lib/voice/mediaServer.js +8 -1
  27. package/build/lib/voice/mediaServer.js.map +1 -1
  28. package/build/lib/voice/replyFormat.js +158 -0
  29. package/build/lib/voice/replyFormat.js.map +1 -0
  30. package/build/lib/voice/stt.js +2 -3
  31. package/build/lib/voice/stt.js.map +1 -1
  32. package/build/lib/voice/vad.js +42 -5
  33. package/build/lib/voice/vad.js.map +1 -1
  34. package/build/main.js +153 -3
  35. package/build/main.js.map +1 -1
  36. package/io-package.json +27 -27
  37. package/package.json +1 -1
  38. package/admin/custom/assets/Components-CCl7INIF.js +0 -2
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LEGACY_FORMAT = exports.CONTENT_TYPES = void 0;
4
+ exports.chooseReplyFormat = chooseReplyFormat;
5
+ exports.resamplePcm = resamplePcm;
6
+ exports.monoToStereo = monoToStereo;
7
+ exports.encodeReply = encodeReply;
8
+ /**
9
+ * Which audio format an ESPHome satellite gets its spoken replies in.
10
+ *
11
+ * A media player announces the formats it takes (`ListEntitiesMediaPlayerResponse.supported_formats`, one per
12
+ * purpose: default playback and announcements). Home Assistant transcodes every reply to the announcement format,
13
+ * so that is the only one a firmware is tested with — and since ESPHome 2026.10 often the only one it can decode at
14
+ * all: the player is built with just the codecs its pipelines use. We do the same: the announcement format first,
15
+ * at its sample rate and channel count. A device that lists nothing (linux-voice-assistant, older firmware) keeps
16
+ * getting WAV as before.
17
+ */
18
+ const flac_1 = require("./flac");
19
+ const stt_1 = require("./stt");
20
+ /** Codecs written here, without any tool. */
21
+ const NATIVE = ['flac', 'wav'];
22
+ const EXTERNAL = ['mp3', 'opus'];
23
+ exports.CONTENT_TYPES = {
24
+ wav: 'audio/wav',
25
+ flac: 'audio/flac',
26
+ mp3: 'audio/mpeg',
27
+ // ESPHome takes Opus only as `audio/ogg` with an opus codecs parameter.
28
+ opus: 'audio/ogg; codecs=opus',
29
+ };
30
+ /** The format replies have always had: WAV at the rate of the TTS engine. */
31
+ exports.LEGACY_FORMAT = { codec: 'wav', sampleRate: 0, channels: 1, source: '' };
32
+ function describe(f) {
33
+ return `${f.format}${f.sampleRate ? ` ${f.sampleRate} Hz` : ''}${f.channels ? ` ×${f.channels}` : ''} (${f.purpose})`;
34
+ }
35
+ /**
36
+ * Pick the reply format from what the device's media players announced.
37
+ *
38
+ * @param formats every `supported_formats` entry of the device
39
+ * @param external whether MP3/Opus can be produced (ffmpeg)
40
+ */
41
+ function chooseReplyFormat(formats, external) {
42
+ if (!formats.length) {
43
+ return exports.LEGACY_FORMAT;
44
+ }
45
+ const usable = (f) => {
46
+ const codec = f.format.trim().toLowerCase();
47
+ return NATIVE.includes(codec) || (external && EXTERNAL.includes(codec)) ? codec : null;
48
+ };
49
+ // Announcements first (that is how a reply is played), our own codecs before ffmpeg ones.
50
+ const ordered = [
51
+ ...formats.filter(f => f.purpose === 'announcement'),
52
+ ...formats.filter(f => f.purpose !== 'announcement'),
53
+ ];
54
+ const pick = ordered.find(f => {
55
+ const codec = usable(f);
56
+ return codec !== null && NATIVE.includes(codec);
57
+ }) ?? ordered.find(f => usable(f) !== null);
58
+ if (!pick) {
59
+ // Nothing we can write (or `none` = the device takes anything): WAV, as before.
60
+ return { ...exports.LEGACY_FORMAT, source: ordered.map(describe).join(', ') };
61
+ }
62
+ return {
63
+ codec: usable(pick),
64
+ sampleRate: pick.sampleRate || 0,
65
+ channels: pick.channels === 2 ? 2 : 1,
66
+ source: describe(pick),
67
+ };
68
+ }
69
+ /** Filter taps per side at the lower of the two rates, and the number of precomputed sub-sample positions. */
70
+ const ZERO_CROSSINGS = 8;
71
+ const PHASES = 128;
72
+ /**
73
+ * Resample mono s16le PCM with a Hann-windowed sinc. Cheap enough for the voice path (a table per sub-sample
74
+ * position, ~20 taps when upsampling), and — unlike linear interpolation — it filters before it decimates, so a
75
+ * 24 kHz voice taken down to 16 kHz does not fold its sibilants back into the speech band.
76
+ */
77
+ function resamplePcm(pcm, from, to) {
78
+ const n = pcm.length >> 1;
79
+ if (from === to || !n || from <= 0 || to <= 0) {
80
+ return pcm;
81
+ }
82
+ const input = new Float32Array(n);
83
+ for (let i = 0; i < n; i++) {
84
+ input[i] = pcm.readInt16LE(i * 2);
85
+ }
86
+ const ratio = to / from;
87
+ // A little below the Nyquist frequency of the lower rate.
88
+ const cutoff = Math.min(1, ratio) * 0.95;
89
+ const half = Math.ceil(ZERO_CROSSINGS / cutoff);
90
+ const taps = 2 * half;
91
+ const table = new Float32Array((PHASES + 1) * taps);
92
+ for (let p = 0; p <= PHASES; p++) {
93
+ const frac = p / PHASES;
94
+ let sum = 0;
95
+ for (let k = 0; k < taps; k++) {
96
+ const d = k - half + 1 - frac; // distance of the input sample from the output position
97
+ const x = d * cutoff;
98
+ const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
99
+ const w = Math.abs(d) >= half ? 0 : 0.5 + 0.5 * Math.cos((Math.PI * d) / half);
100
+ table[p * taps + k] = sinc * w;
101
+ sum += sinc * w;
102
+ }
103
+ for (let k = 0; k < taps; k++) {
104
+ table[p * taps + k] /= sum; // unity gain at every position: no ripple on a constant level
105
+ }
106
+ }
107
+ const outLen = Math.max(1, Math.round(n * ratio));
108
+ const out = Buffer.alloc(outLen * 2);
109
+ for (let i = 0; i < outLen; i++) {
110
+ const center = i / ratio;
111
+ const base = Math.floor(center);
112
+ const row = Math.round((center - base) * PHASES) * taps;
113
+ const first = base - half + 1;
114
+ let acc = 0;
115
+ for (let k = 0; k < taps; k++) {
116
+ const j = first + k;
117
+ if (j >= 0 && j < n) {
118
+ acc += input[j] * table[row + k];
119
+ }
120
+ }
121
+ out.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(acc))), i * 2);
122
+ }
123
+ return out;
124
+ }
125
+ /** Duplicate mono s16le PCM into interleaved stereo. */
126
+ function monoToStereo(pcm) {
127
+ const n = pcm.length >> 1;
128
+ const out = Buffer.alloc(n * 4);
129
+ for (let i = 0; i < n; i++) {
130
+ const v = pcm.readInt16LE(i * 2);
131
+ out.writeInt16LE(v, i * 4);
132
+ out.writeInt16LE(v, i * 4 + 2);
133
+ }
134
+ return out;
135
+ }
136
+ /**
137
+ * Encode a mono reply in `format`. MP3/Opus go through `external`; everything else is written here.
138
+ * Throws when `external` is needed but missing or fails — the caller falls back to a native format.
139
+ */
140
+ async function encodeReply(pcm, sampleRate, format, external) {
141
+ const rate = format.sampleRate || sampleRate;
142
+ if (format.codec === 'mp3' || format.codec === 'opus') {
143
+ if (!external) {
144
+ throw new Error(`no encoder for ${format.codec}`);
145
+ }
146
+ const body = await external(pcm, sampleRate, {
147
+ codec: format.codec,
148
+ sampleRate: rate,
149
+ channels: format.channels,
150
+ });
151
+ return { body, contentType: exports.CONTENT_TYPES[format.codec] };
152
+ }
153
+ const resampled = resamplePcm(pcm, sampleRate, rate);
154
+ const samples = format.channels === 2 ? monoToStereo(resampled) : resampled;
155
+ const body = format.codec === 'flac' ? (0, flac_1.encodeFlac)(samples, rate, format.channels) : (0, stt_1.pcmToWav)(samples, rate, format.channels);
156
+ return { body, contentType: exports.CONTENT_TYPES[format.codec] };
157
+ }
158
+ //# sourceMappingURL=replyFormat.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replyFormat.js","sourceRoot":"","sources":["../../../src/lib/voice/replyFormat.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Which audio format an ESPHome satellite gets its spoken replies in.\n *\n * A media player announces the formats it takes (`ListEntitiesMediaPlayerResponse.supported_formats`, one per\n * purpose: default playback and announcements). Home Assistant transcodes every reply to the announcement format,\n * so that is the only one a firmware is tested with — and since ESPHome 2026.10 often the only one it can decode at\n * all: the player is built with just the codecs its pipelines use. We do the same: the announcement format first,\n * at its sample rate and channel count. A device that lists nothing (linux-voice-assistant, older firmware) keeps\n * getting WAV as before.\n */\nimport { encodeFlac } from './flac';\nimport { pcmToWav } from './stt';\n\n/** One entry of `supported_formats`. */\nexport interface MediaFormat {\n /** `flac`, `mp3`, `wav`, `opus`, `none` … as the device names it. */\n format: string;\n /** 0 = no preference. */\n sampleRate: number;\n /** 0 = no preference. */\n channels: number;\n purpose: 'default' | 'announcement';\n sampleBytes: number;\n}\n\nexport type ReplyCodec = 'wav' | 'flac' | 'mp3' | 'opus';\n\nexport interface ReplyFormat {\n codec: ReplyCodec;\n /** Target rate; 0 keeps the rate of the TTS engine. */\n sampleRate: number;\n channels: 1 | 2;\n /** What the device asked for, for the log. */\n source: string;\n}\n\n/** Turns PCM into a codec we cannot write ourselves (MP3, Opus) — ffmpeg in the adapter. */\nexport type ExternalEncoder = (\n pcm: Buffer,\n sampleRate: number,\n target: { codec: 'mp3' | 'opus'; sampleRate: number; channels: number },\n) => Promise<Buffer>;\n\n/** Codecs written here, without any tool. */\nconst NATIVE: ReplyCodec[] = ['flac', 'wav'];\nconst EXTERNAL: ReplyCodec[] = ['mp3', 'opus'];\n\nexport const CONTENT_TYPES: Record<ReplyCodec, string> = {\n wav: 'audio/wav',\n flac: 'audio/flac',\n mp3: 'audio/mpeg',\n // ESPHome takes Opus only as `audio/ogg` with an opus codecs parameter.\n opus: 'audio/ogg; codecs=opus',\n};\n\n/** The format replies have always had: WAV at the rate of the TTS engine. */\nexport const LEGACY_FORMAT: ReplyFormat = { codec: 'wav', sampleRate: 0, channels: 1, source: '' };\n\nfunction describe(f: MediaFormat): string {\n return `${f.format}${f.sampleRate ? ` ${f.sampleRate} Hz` : ''}${f.channels ? ` ×${f.channels}` : ''} (${f.purpose})`;\n}\n\n/**\n * Pick the reply format from what the device's media players announced.\n *\n * @param formats every `supported_formats` entry of the device\n * @param external whether MP3/Opus can be produced (ffmpeg)\n */\nexport function chooseReplyFormat(formats: MediaFormat[], external: boolean): ReplyFormat {\n if (!formats.length) {\n return LEGACY_FORMAT;\n }\n const usable = (f: MediaFormat): ReplyCodec | null => {\n const codec = f.format.trim().toLowerCase() as ReplyCodec;\n return NATIVE.includes(codec) || (external && EXTERNAL.includes(codec)) ? codec : null;\n };\n // Announcements first (that is how a reply is played), our own codecs before ffmpeg ones.\n const ordered = [\n ...formats.filter(f => f.purpose === 'announcement'),\n ...formats.filter(f => f.purpose !== 'announcement'),\n ];\n const pick =\n ordered.find(f => {\n const codec = usable(f);\n return codec !== null && NATIVE.includes(codec);\n }) ?? ordered.find(f => usable(f) !== null);\n if (!pick) {\n // Nothing we can write (or `none` = the device takes anything): WAV, as before.\n return { ...LEGACY_FORMAT, source: ordered.map(describe).join(', ') };\n }\n return {\n codec: usable(pick) as ReplyCodec,\n sampleRate: pick.sampleRate || 0,\n channels: pick.channels === 2 ? 2 : 1,\n source: describe(pick),\n };\n}\n\n/** Filter taps per side at the lower of the two rates, and the number of precomputed sub-sample positions. */\nconst ZERO_CROSSINGS = 8;\nconst PHASES = 128;\n\n/**\n * Resample mono s16le PCM with a Hann-windowed sinc. Cheap enough for the voice path (a table per sub-sample\n * position, ~20 taps when upsampling), and — unlike linear interpolation — it filters before it decimates, so a\n * 24 kHz voice taken down to 16 kHz does not fold its sibilants back into the speech band.\n */\nexport function resamplePcm(pcm: Buffer, from: number, to: number): Buffer {\n const n = pcm.length >> 1;\n if (from === to || !n || from <= 0 || to <= 0) {\n return pcm;\n }\n const input = new Float32Array(n);\n for (let i = 0; i < n; i++) {\n input[i] = pcm.readInt16LE(i * 2);\n }\n const ratio = to / from;\n // A little below the Nyquist frequency of the lower rate.\n const cutoff = Math.min(1, ratio) * 0.95;\n const half = Math.ceil(ZERO_CROSSINGS / cutoff);\n const taps = 2 * half;\n const table = new Float32Array((PHASES + 1) * taps);\n for (let p = 0; p <= PHASES; p++) {\n const frac = p / PHASES;\n let sum = 0;\n for (let k = 0; k < taps; k++) {\n const d = k - half + 1 - frac; // distance of the input sample from the output position\n const x = d * cutoff;\n const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);\n const w = Math.abs(d) >= half ? 0 : 0.5 + 0.5 * Math.cos((Math.PI * d) / half);\n table[p * taps + k] = sinc * w;\n sum += sinc * w;\n }\n for (let k = 0; k < taps; k++) {\n table[p * taps + k] /= sum; // unity gain at every position: no ripple on a constant level\n }\n }\n const outLen = Math.max(1, Math.round(n * ratio));\n const out = Buffer.alloc(outLen * 2);\n for (let i = 0; i < outLen; i++) {\n const center = i / ratio;\n const base = Math.floor(center);\n const row = Math.round((center - base) * PHASES) * taps;\n const first = base - half + 1;\n let acc = 0;\n for (let k = 0; k < taps; k++) {\n const j = first + k;\n if (j >= 0 && j < n) {\n acc += input[j] * table[row + k];\n }\n }\n out.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(acc))), i * 2);\n }\n return out;\n}\n\n/** Duplicate mono s16le PCM into interleaved stereo. */\nexport function monoToStereo(pcm: Buffer): Buffer {\n const n = pcm.length >> 1;\n const out = Buffer.alloc(n * 4);\n for (let i = 0; i < n; i++) {\n const v = pcm.readInt16LE(i * 2);\n out.writeInt16LE(v, i * 4);\n out.writeInt16LE(v, i * 4 + 2);\n }\n return out;\n}\n\n/**\n * Encode a mono reply in `format`. MP3/Opus go through `external`; everything else is written here.\n * Throws when `external` is needed but missing or fails — the caller falls back to a native format.\n */\nexport async function encodeReply(\n pcm: Buffer,\n sampleRate: number,\n format: ReplyFormat,\n external?: ExternalEncoder,\n): Promise<{ body: Buffer; contentType: string }> {\n const rate = format.sampleRate || sampleRate;\n if (format.codec === 'mp3' || format.codec === 'opus') {\n if (!external) {\n throw new Error(`no encoder for ${format.codec}`);\n }\n const body = await external(pcm, sampleRate, {\n codec: format.codec,\n sampleRate: rate,\n channels: format.channels,\n });\n return { body, contentType: CONTENT_TYPES[format.codec] };\n }\n const resampled = resamplePcm(pcm, sampleRate, rate);\n const samples = format.channels === 2 ? monoToStereo(resampled) : resampled;\n const body =\n format.codec === 'flac' ? encodeFlac(samples, rate, format.channels) : pcmToWav(samples, rate, format.channels);\n return { body, contentType: CONTENT_TYPES[format.codec] };\n}\n"]}
@@ -67,9 +67,8 @@ function hintsToPrompt(hints, maxChars = 800) {
67
67
  }
68
68
  return parts.join(', ');
69
69
  }
70
- /** Wrap raw mono 16-bit-signed-LE PCM into a minimal 44-byte WAV container. */
71
- function pcmToWav(pcm, sampleRate) {
72
- const numChannels = 1;
70
+ /** Wrap raw 16-bit-signed-LE PCM (mono, or interleaved stereo) into a minimal 44-byte WAV container. */
71
+ function pcmToWav(pcm, sampleRate, numChannels = 1) {
73
72
  const bitsPerSample = 16;
74
73
  const blockAlign = (numChannels * bitsPerSample) / 8;
75
74
  const byteRate = sampleRate * blockAlign;
@@ -1 +1 @@
1
- {"version":3,"file":"stt.js","sourceRoot":"","sources":["../../../src/lib/voice/stt.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Speech-to-text engines. V1 ships the cloud engine (OpenAI Whisper); local engines\n * (Vosk / whisper.cpp / Wyoming) plug in behind the same `SttEngine` interface later.\n */\nimport OpenAI, { toFile } from 'openai';\n\nexport interface SttEngine {\n /**\n * Transcribe raw mono 16-bit PCM. `lang` is an ISO-639-1 hint ('' = auto-detect).\n *\n * `hints` is an optional domain vocabulary (device / room / scene names …) to bias recognition toward,\n * so the engine better transcribes those proper nouns. Only engines that support *soft* biasing use it\n * (OpenAI Whisper `prompt`, Azure phrase list); engines where a word list would be a hard constraint or\n * needs pre-registration (Vosk grammar, AWS custom vocabulary) ignore it.\n */\n transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string>;\n /** Optional warm-up (install/download engine + model for `lang`) so the first request isn't slow. */\n prepare?(lang: string): Promise<void>;\n}\n\n/**\n * Join vocabulary hints into a Whisper `prompt` string (a soft bias toward these terms), de-duplicated and\n * length-bounded to stay within the model's ~224-token prompt budget. Returns '' when there are no hints.\n */\nexport function hintsToPrompt(hints: string[] | undefined, maxChars = 800): string {\n if (!hints?.length) {\n return '';\n }\n const seen = new Set<string>();\n const parts: string[] = [];\n let len = 0;\n for (const h of hints) {\n const term = (h || '').trim();\n const key = term.toLowerCase();\n if (!term || seen.has(key)) {\n continue;\n }\n if (len + term.length + 2 > maxChars) {\n break;\n }\n seen.add(key);\n parts.push(term);\n len += term.length + 2;\n }\n return parts.join(', ');\n}\n\n/** Wrap raw mono 16-bit-signed-LE PCM into a minimal 44-byte WAV container. */\nexport function pcmToWav(pcm: Buffer, sampleRate: number): Buffer {\n const numChannels = 1;\n const bitsPerSample = 16;\n const blockAlign = (numChannels * bitsPerSample) / 8;\n const byteRate = sampleRate * blockAlign;\n const header = Buffer.alloc(44);\n header.write('RIFF', 0);\n header.writeUInt32LE(36 + pcm.length, 4);\n header.write('WAVE', 8);\n header.write('fmt ', 12);\n header.writeUInt32LE(16, 16); // PCM fmt chunk size\n header.writeUInt16LE(1, 20); // audio format = PCM\n header.writeUInt16LE(numChannels, 22);\n header.writeUInt32LE(sampleRate, 24);\n header.writeUInt32LE(byteRate, 28);\n header.writeUInt16LE(blockAlign, 32);\n header.writeUInt16LE(bitsPerSample, 34);\n header.write('data', 36);\n header.writeUInt32LE(pcm.length, 40);\n return Buffer.concat([header, pcm]);\n}\n\n/** Cloud STT via the OpenAI transcription API (Whisper). */\nexport class OpenAiStt implements SttEngine {\n private readonly client: OpenAI;\n private readonly model: string;\n\n constructor(apiKey: string, model = 'whisper-1') {\n this.client = new OpenAI({ apiKey });\n this.model = model;\n }\n\n async transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string> {\n const file = await toFile(pcmToWav(pcm, sampleRate), 'audio.wav', { type: 'audio/wav' });\n // A prompt biases Whisper toward the given domain vocabulary (device/room names) without excluding\n // other words — improves recognition of proper nouns the NLU then needs to match.\n const prompt = hintsToPrompt(hints);\n const res = await this.client.audio.transcriptions.create({\n file,\n model: this.model,\n // Whisper wants the bare ISO-639-1 code; strip any region (de-DE → de). '' → auto-detect.\n language: (lang || '').split('-')[0] || undefined,\n ...(prompt ? { prompt } : {}),\n });\n return (res.text || '').trim();\n }\n}\n"]}
1
+ {"version":3,"file":"stt.js","sourceRoot":"","sources":["../../../src/lib/voice/stt.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Speech-to-text engines. V1 ships the cloud engine (OpenAI Whisper); local engines\n * (Vosk / whisper.cpp / Wyoming) plug in behind the same `SttEngine` interface later.\n */\nimport OpenAI, { toFile } from 'openai';\n\nexport interface SttEngine {\n /**\n * Transcribe raw mono 16-bit PCM. `lang` is an ISO-639-1 hint ('' = auto-detect).\n *\n * `hints` is an optional domain vocabulary (device / room / scene names …) to bias recognition toward,\n * so the engine better transcribes those proper nouns. Only engines that support *soft* biasing use it\n * (OpenAI Whisper `prompt`, Azure phrase list); engines where a word list would be a hard constraint or\n * needs pre-registration (Vosk grammar, AWS custom vocabulary) ignore it.\n */\n transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string>;\n /** Optional warm-up (install/download engine + model for `lang`) so the first request isn't slow. */\n prepare?(lang: string): Promise<void>;\n}\n\n/**\n * Join vocabulary hints into a Whisper `prompt` string (a soft bias toward these terms), de-duplicated and\n * length-bounded to stay within the model's ~224-token prompt budget. Returns '' when there are no hints.\n */\nexport function hintsToPrompt(hints: string[] | undefined, maxChars = 800): string {\n if (!hints?.length) {\n return '';\n }\n const seen = new Set<string>();\n const parts: string[] = [];\n let len = 0;\n for (const h of hints) {\n const term = (h || '').trim();\n const key = term.toLowerCase();\n if (!term || seen.has(key)) {\n continue;\n }\n if (len + term.length + 2 > maxChars) {\n break;\n }\n seen.add(key);\n parts.push(term);\n len += term.length + 2;\n }\n return parts.join(', ');\n}\n\n/** Wrap raw 16-bit-signed-LE PCM (mono, or interleaved stereo) into a minimal 44-byte WAV container. */\nexport function pcmToWav(pcm: Buffer, sampleRate: number, numChannels = 1): Buffer {\n const bitsPerSample = 16;\n const blockAlign = (numChannels * bitsPerSample) / 8;\n const byteRate = sampleRate * blockAlign;\n const header = Buffer.alloc(44);\n header.write('RIFF', 0);\n header.writeUInt32LE(36 + pcm.length, 4);\n header.write('WAVE', 8);\n header.write('fmt ', 12);\n header.writeUInt32LE(16, 16); // PCM fmt chunk size\n header.writeUInt16LE(1, 20); // audio format = PCM\n header.writeUInt16LE(numChannels, 22);\n header.writeUInt32LE(sampleRate, 24);\n header.writeUInt32LE(byteRate, 28);\n header.writeUInt16LE(blockAlign, 32);\n header.writeUInt16LE(bitsPerSample, 34);\n header.write('data', 36);\n header.writeUInt32LE(pcm.length, 40);\n return Buffer.concat([header, pcm]);\n}\n\n/** Cloud STT via the OpenAI transcription API (Whisper). */\nexport class OpenAiStt implements SttEngine {\n private readonly client: OpenAI;\n private readonly model: string;\n\n constructor(apiKey: string, model = 'whisper-1') {\n this.client = new OpenAI({ apiKey });\n this.model = model;\n }\n\n async transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string> {\n const file = await toFile(pcmToWav(pcm, sampleRate), 'audio.wav', { type: 'audio/wav' });\n // A prompt biases Whisper toward the given domain vocabulary (device/room names) without excluding\n // other words — improves recognition of proper nouns the NLU then needs to match.\n const prompt = hintsToPrompt(hints);\n const res = await this.client.audio.transcriptions.create({\n file,\n model: this.model,\n // Whisper wants the bare ISO-639-1 code; strip any region (de-DE → de). '' → auto-detect.\n language: (lang || '').split('-')[0] || undefined,\n ...(prompt ? { prompt } : {}),\n });\n return (res.text || '').trim();\n }\n}\n"]}
@@ -61,6 +61,8 @@ class SpeechDetector {
61
61
  silenceMs;
62
62
  longSilenceMs;
63
63
  longAfterMs;
64
+ minSpeechMs;
65
+ speechGapMs;
64
66
  maxMs;
65
67
  startTimeoutMs;
66
68
  skipMs;
@@ -79,6 +81,14 @@ class SpeechDetector {
79
81
  elapsedMs = 0;
80
82
  /** Start of the frame that crossed {@link startLevel}, in ms from the start of the recording; -1 = not yet. */
81
83
  speechStartMs = -1;
84
+ /** A crossing of {@link startLevel} that has not yet lasted {@link minSpeechMs}: its start, -1 = none. */
85
+ candidateMs = -1;
86
+ /** Quiet since the last loud frame of the candidate. */
87
+ candidateQuietMs = 0;
88
+ /** Sound above {@link endLevel} since the candidate (and then the speech) started, in ms. */
89
+ voicedMs = 0;
90
+ /** The longest sound that crossed {@link startLevel}, in ms — also one too short to count as speech. */
91
+ longestSoundMs = 0;
82
92
  /** Audio analysed so far, in milliseconds (exposed for logging). */
83
93
  totalMs = 0;
84
94
  /** Loudest frame seen so far (exposed for logging — the usual clue when a mic is mis-configured). */
@@ -88,7 +98,7 @@ class SpeechDetector {
88
98
  * it tells whether a dropped utterance was silence or a voice that was simply too quiet for the gate.
89
99
  */
90
100
  analysedPeak = 0;
91
- constructor({ sampleRate = 16000, frameMs = 30, startLevel = 700, endLevel = 400, silenceMs = 900, longSilenceMs = 0, longAfterMs = 0, maxMs = 12000, startTimeoutMs = 0, skipMs = 0, adaptive = false, minStartLevel = 250, minEndLevel = 150, startFactor = 3.5, endFactor = 2, warmupMs = 150, } = {}) {
101
+ constructor({ sampleRate = 16000, frameMs = 30, startLevel = 700, endLevel = 400, silenceMs = 900, longSilenceMs = 0, longAfterMs = 0, minSpeechMs = 0, speechGapMs = 300, maxMs = 12000, startTimeoutMs = 0, skipMs = 0, adaptive = false, minStartLevel = 250, minEndLevel = 150, startFactor = 3.5, endFactor = 2, warmupMs = 150, } = {}) {
92
102
  this.frameBytes = Math.round((sampleRate * frameMs) / 1000) * 2;
93
103
  this.frameMs = frameMs;
94
104
  this.fixedStart = startLevel;
@@ -96,6 +106,8 @@ class SpeechDetector {
96
106
  this.silenceMs = silenceMs;
97
107
  this.longSilenceMs = longSilenceMs;
98
108
  this.longAfterMs = longAfterMs;
109
+ this.minSpeechMs = minSpeechMs;
110
+ this.speechGapMs = speechGapMs;
99
111
  this.maxMs = maxMs;
100
112
  this.startTimeoutMs = startTimeoutMs;
101
113
  this.skipMs = skipMs;
@@ -145,6 +157,7 @@ class SpeechDetector {
145
157
  startLevel: Math.round(this.startLevel),
146
158
  endLevel: Math.round(this.endLevel),
147
159
  speechStartMs: this.speechSeen ? this.speechStartMs : undefined,
160
+ voicedMs: this.speechSeen ? this.voicedMs : undefined,
148
161
  silenceMs: this.requiredSilenceMs,
149
162
  };
150
163
  }
@@ -184,7 +197,7 @@ class SpeechDetector {
184
197
  ? 0
185
198
  : level < this.floor
186
199
  ? level
187
- : this.speechSeen
200
+ : this.speechSeen || this.candidateMs >= 0
188
201
  ? this.floor
189
202
  : this.floor + (level - this.floor) * NOISE_RISE;
190
203
  if (this.totalMs < this.warmupMs) {
@@ -192,10 +205,33 @@ class SpeechDetector {
192
205
  }
193
206
  }
194
207
  if (!this.speechSeen) {
195
- if (level >= this.startLevel) {
208
+ // A loud frame opens a candidate; it becomes speech once it has lasted `minSpeechMs`, and is
209
+ // forgotten when it falls quiet for `speechGapMs` before that — a click is not a request.
210
+ if (this.candidateMs < 0) {
211
+ if (level >= this.startLevel) {
212
+ this.candidateMs = this.elapsedMs - this.frameMs;
213
+ this.candidateQuietMs = 0;
214
+ this.voicedMs = this.frameMs;
215
+ this.longestSoundMs = Math.max(this.longestSoundMs, this.voicedMs);
216
+ }
217
+ }
218
+ else if (level >= this.endLevel) {
219
+ this.voicedMs += this.frameMs;
220
+ this.candidateQuietMs = 0;
221
+ this.longestSoundMs = Math.max(this.longestSoundMs, this.voicedMs);
222
+ }
223
+ else {
224
+ this.candidateQuietMs += this.frameMs;
225
+ if (this.candidateQuietMs >= this.speechGapMs) {
226
+ this.candidateMs = -1;
227
+ this.voicedMs = 0;
228
+ }
229
+ }
230
+ if (this.candidateMs >= 0 && this.voicedMs >= this.minSpeechMs) {
196
231
  this.speechSeen = true;
197
- this.speechStartMs = this.elapsedMs - this.frameMs;
198
- this.silentMs = 0;
232
+ this.speechStartMs = this.candidateMs;
233
+ // A gap inside the candidate already counts towards the end of the utterance.
234
+ this.silentMs = this.candidateQuietMs;
199
235
  }
200
236
  }
201
237
  else if (level < this.endLevel) {
@@ -206,6 +242,7 @@ class SpeechDetector {
206
242
  }
207
243
  else {
208
244
  this.silentMs = 0;
245
+ this.voicedMs += this.frameMs;
209
246
  }
210
247
  if (!this.speechSeen && this.startTimeoutMs > 0 && this.elapsedMs >= this.startTimeoutMs) {
211
248
  verdict ||= 'no-speech';
@@ -1 +1 @@
1
- {"version":3,"file":"vad.js","sourceRoot":"","sources":["../../../src/lib/voice/vad.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * End-of-speech detection for satellites that stream microphone audio until they are told to stop.\n *\n * The Hannah/UDP protocol has the satellite decide when an utterance ended (`audio_end`), and Wyoming\n * clients send `audio-stop` themselves. ESPHome voice satellites do neither: they keep streaming until\n * the server sends `STT_VAD_END`/`STT_END`, so the decision has to happen here.\n *\n * This is a plain energy gate, not a neural VAD — good enough to close an utterance, and it costs\n * nothing per frame. Speech must first rise above `startLevel`; after that, `silenceMs` worth of frames\n * below `endLevel` ends it. `maxMs` is the hard stop for rooms that are simply loud.\n *\n * Fixed thresholds only work when the microphone level is known, and on a real satellite it is not.\n * Measured on a ThirdReality Voice & Music Assistant: the device plays a wake-acknowledgement chirp\n * into its own microphone that peaks at full scale (RMS ~16000, clipped), while the speech that follows\n * arrives at RMS 358–469 — below the 700 default for `startLevel` and right on the 400 for `endLevel`.\n * That breaks the gate twice over: `sawSpeech` is satisfied by the chirp rather than by any voice, so\n * the \"nothing but speech-free audio\" guard never fires, and half the real speech frames count as\n * silence, so the utterance can be cut off mid-sentence.\n *\n * Hence two opt-in knobs, both enabled by the ESPHome transport:\n *\n * - `skipMs` drops the leading chirp from the *analysis* only. What reaches STT is cut later, from where\n * speech was detected minus a margin (`speechAudio.ts`) — a fast speaker may already be talking.\n * - `adaptive` derives the thresholds from a tracked noise floor instead of fixed levels. The floor\n * follows the quietest frame seen (instant down, very slow up), so it settles on room noise even when\n * someone starts talking immediately — which a mean or median over a calibration window would not.\n *\n * And one for longer requests: `longSilenceMs` after `longAfterMs` of speech. In a quiet room the silence\n * threshold sits at its minimum, and a voice a few metres away dips below it between syllables — on a\n * ThirdReality speaker a whole \"Мессенджер\" (0.7 s) stayed under 150. A pause for thought on top of that\n * ended a long request mid-sentence, while short commands should keep closing quickly.\n */\n\n/**\n * A buffer that may be a view onto any `ArrayBufferLike` — what `Buffer.subarray()` and\n * `Buffer.concat()` hand back. Plain `Buffer` (= `Buffer<ArrayBuffer>`) assigns to it, not the reverse.\n */\nexport type Bytes = Buffer<ArrayBufferLike>;\n\n/** Root-mean-square amplitude (0…32767) of a mono 16-bit signed LE PCM buffer. */\nexport function rms(pcm: Bytes): number {\n const samples = Math.floor(pcm.length / 2);\n if (!samples) {\n return 0;\n }\n let sum = 0;\n for (let i = 0; i < samples; i++) {\n const value = pcm.readInt16LE(i * 2);\n sum += value * value;\n }\n return Math.sqrt(sum / samples);\n}\n\nexport interface SpeechDetectorOptions {\n /** Sample rate of the incoming PCM (ESPHome satellites always capture at 16 kHz). */\n sampleRate?: number;\n /** Analysis window; 30 ms is the usual compromise between latency and stability. */\n frameMs?: number;\n /** RMS above which we consider speech to have started. Ignored when `adaptive` is on. */\n startLevel?: number;\n /** RMS below which a frame counts as silence. Ignored when `adaptive` is on. */\n endLevel?: number;\n /** Silence after speech that ends the utterance. */\n silenceMs?: number;\n /**\n * Silence that ends the utterance once the speaker has talked for {@link longAfterMs} (0 = always\n * {@link silenceMs}). Somebody dictating a longer request pauses to think, and a short command should\n * still close fast.\n */\n longSilenceMs?: number;\n /** Speech (from its start to the pause) after which {@link longSilenceMs} applies; 0 = never. */\n longAfterMs?: number;\n /** Hard cap on one utterance, in case the room never falls quiet. */\n maxMs?: number;\n /**\n * Give up when no speech has started this long after the stream began (the skipped lead-in included);\n * 0 = only {@link maxMs}. A false wake would otherwise keep the microphone open for the whole `maxMs`.\n */\n startTimeoutMs?: number;\n /**\n * Leading audio to leave out of the analysis — for devices that chirp into their own microphone\n * when the wake word fires. The audio itself is untouched; this only stops the chirp from being\n * mistaken for speech and from poisoning the noise floor.\n */\n skipMs?: number;\n /** Derive the thresholds from the tracked noise floor instead of using fixed levels. */\n adaptive?: boolean;\n /** Adaptive mode: `startLevel` is at least this, however quiet the room is. */\n minStartLevel?: number;\n /** Adaptive mode: `endLevel` is at least this. */\n minEndLevel?: number;\n /** Adaptive mode: speech threshold as a multiple of the noise floor. */\n startFactor?: number;\n /** Adaptive mode: silence threshold as a multiple of the noise floor. */\n endFactor?: number;\n /** Adaptive mode: settle the noise floor this long before speech may be declared. */\n warmupMs?: number;\n}\n\n/** Why an utterance ended: the speaker stopped, we hit the hard cap, or nobody started speaking at all. */\nexport type SpeechEnd = 'speech-end' | 'timeout' | 'no-speech';\n\n/** What the detector made of one recording — for the request trace and the diagnosis recordings. */\nexport interface VadReport {\n /** Loudest frame, the wake chirp included. */\n peak: number;\n /** Loudest frame after the skipped lead-in. */\n analysedPeak: number;\n noiseFloor: number;\n startLevel: number;\n endLevel: number;\n /** Where speech began, in ms from the start of the recording; absent when it never did. */\n speechStartMs?: number;\n /** Why the recording ended; 'stream' = the device stopped streaming by itself. */\n end?: SpeechEnd | 'stream';\n /** Silence that was needed to end it at that point (longer once the speaker talked for a while). */\n silenceMs?: number;\n /** RMS per 100 ms of the whole recording. */\n levels?: number[];\n /** What went to speech recognition: the recording cut to the speech, and the gain applied to it. */\n stt?: { fromMs: number; toMs: number; gain: number };\n}\n\n/**\n * How fast the tracked noise floor may follow a *rising* ambient level, per analysed frame (30 ms).\n * Fast enough that a genuinely noisy room is matched within about a second, slow enough that a voice\n * cannot pull the floor up to its own level before it has been recognised as speech.\n */\nconst NOISE_RISE = 0.02;\n\nexport class SpeechDetector {\n private readonly frameBytes: number;\n private readonly frameMs: number;\n private readonly fixedStart: number;\n private readonly fixedEnd: number;\n private readonly silenceMs: number;\n private readonly longSilenceMs: number;\n private readonly longAfterMs: number;\n private readonly maxMs: number;\n private readonly startTimeoutMs: number;\n private readonly skipMs: number;\n private readonly adaptive: boolean;\n private readonly minStartLevel: number;\n private readonly minEndLevel: number;\n private readonly startFactor: number;\n private readonly endFactor: number;\n private readonly warmupMs: number;\n\n private pending: Bytes = Buffer.alloc(0);\n private speechSeen = false;\n private silentMs = 0;\n /** Tracked room level in adaptive mode; null until the first analysed frame. */\n private floor: number | null = null;\n /** Audio seen so far *including* the skipped lead-in, in milliseconds. */\n private elapsedMs = 0;\n /** Start of the frame that crossed {@link startLevel}, in ms from the start of the recording; -1 = not yet. */\n speechStartMs = -1;\n /** Audio analysed so far, in milliseconds (exposed for logging). */\n totalMs = 0;\n /** Loudest frame seen so far (exposed for logging — the usual clue when a mic is mis-configured). */\n peak = 0;\n /**\n * Loudest frame after the skipped lead-in, i.e. without the wake chirp. Compared with {@link startLevel}\n * it tells whether a dropped utterance was silence or a voice that was simply too quiet for the gate.\n */\n analysedPeak = 0;\n\n constructor({\n sampleRate = 16000,\n frameMs = 30,\n startLevel = 700,\n endLevel = 400,\n silenceMs = 900,\n longSilenceMs = 0,\n longAfterMs = 0,\n maxMs = 12000,\n startTimeoutMs = 0,\n skipMs = 0,\n adaptive = false,\n minStartLevel = 250,\n minEndLevel = 150,\n startFactor = 3.5,\n endFactor = 2,\n warmupMs = 150,\n }: SpeechDetectorOptions = {}) {\n this.frameBytes = Math.round((sampleRate * frameMs) / 1000) * 2;\n this.frameMs = frameMs;\n this.fixedStart = startLevel;\n this.fixedEnd = endLevel;\n this.silenceMs = silenceMs;\n this.longSilenceMs = longSilenceMs;\n this.longAfterMs = longAfterMs;\n this.maxMs = maxMs;\n this.startTimeoutMs = startTimeoutMs;\n this.skipMs = skipMs;\n this.adaptive = adaptive;\n this.minStartLevel = minStartLevel;\n this.minEndLevel = minEndLevel;\n this.startFactor = startFactor;\n this.endFactor = endFactor;\n this.warmupMs = warmupMs;\n }\n\n /** True once any frame crossed {@link startLevel} — an utterance that never did is room noise. */\n get sawSpeech(): boolean {\n return this.speechSeen;\n }\n\n /** Tracked room level (adaptive mode), for logging. 0 before the first analysed frame. */\n get noiseFloor(): number {\n return this.floor ?? 0;\n }\n\n /** Current speech threshold — fixed, or derived from the noise floor in adaptive mode. */\n get startLevel(): number {\n return this.adaptive ? Math.max(this.minStartLevel, (this.floor ?? 0) * this.startFactor) : this.fixedStart;\n }\n\n /** Current silence threshold. Never above {@link startLevel}, so the gate keeps its hysteresis. */\n get endLevel(): number {\n if (!this.adaptive) {\n return this.fixedEnd;\n }\n return Math.min(this.startLevel, Math.max(this.minEndLevel, (this.floor ?? 0) * this.endFactor));\n }\n\n /**\n * Silence that ends the utterance right now: {@link silenceMs}, or {@link longSilenceMs} once the speaker\n * has talked for {@link longAfterMs} before the current pause.\n */\n get requiredSilenceMs(): number {\n if (!this.speechSeen || !this.longAfterMs || this.longSilenceMs <= this.silenceMs) {\n return this.silenceMs;\n }\n const spokenMs = this.elapsedMs - this.silentMs - this.speechStartMs;\n return spokenMs >= this.longAfterMs ? this.longSilenceMs : this.silenceMs;\n }\n\n /** The detector's view of the recording so far. */\n report(): VadReport {\n return {\n peak: Math.round(this.peak),\n analysedPeak: Math.round(this.analysedPeak),\n noiseFloor: Math.round(this.noiseFloor),\n startLevel: Math.round(this.startLevel),\n endLevel: Math.round(this.endLevel),\n speechStartMs: this.speechSeen ? this.speechStartMs : undefined,\n silenceMs: this.requiredSilenceMs,\n };\n }\n\n /** Feed one audio chunk (any size). Returns why the utterance ended, or null to keep listening. */\n push(chunk: Bytes): SpeechEnd | null {\n this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;\n let verdict: SpeechEnd | null = null;\n\n while (this.pending.length >= this.frameBytes) {\n const frame = this.pending.subarray(0, this.frameBytes);\n this.pending = this.pending.subarray(this.frameBytes);\n this.elapsedMs += this.frameMs;\n\n const level = rms(frame);\n if (level > this.peak) {\n this.peak = level;\n }\n\n // The device's own wake chirp: loud, clipped, and not speech. Leaving it out of the\n // analysis is what makes `sawSpeech` mean anything, and keeps the noise floor honest.\n if (this.elapsedMs <= this.skipMs) {\n continue;\n }\n this.totalMs += this.frameMs;\n if (level > this.analysedPeak) {\n this.analysedPeak = level;\n }\n\n if (this.adaptive) {\n // Three rules, each there to defeat a specific way of getting this wrong:\n // - start at zero, i.e. maximally sensitive. Seeding from the first frame instead\n // lets someone who talks straight after the wake word install their own voice as\n // the room's noise floor, which shuts the gate for good — the same trap an average\n // or median over a calibration window falls into.\n // - fall instantly, so a room that went quiet is believed at once.\n // - rise slowly, and not at all once speech has been recognised: otherwise a long\n // utterance drags the floor, and with it `endLevel`, up to its own level and cuts\n // itself off mid-sentence. Falling stays on, so the pauses between words still count.\n this.floor =\n this.floor === null\n ? 0\n : level < this.floor\n ? level\n : this.speechSeen\n ? this.floor\n : this.floor + (level - this.floor) * NOISE_RISE;\n if (this.totalMs < this.warmupMs) {\n continue; // let the floor settle before anything may count as speech\n }\n }\n\n if (!this.speechSeen) {\n if (level >= this.startLevel) {\n this.speechSeen = true;\n this.speechStartMs = this.elapsedMs - this.frameMs;\n this.silentMs = 0;\n }\n } else if (level < this.endLevel) {\n this.silentMs += this.frameMs;\n if (this.silentMs >= this.requiredSilenceMs) {\n verdict ||= 'speech-end';\n }\n } else {\n this.silentMs = 0;\n }\n\n if (!this.speechSeen && this.startTimeoutMs > 0 && this.elapsedMs >= this.startTimeoutMs) {\n verdict ||= 'no-speech';\n }\n if (this.totalMs >= this.maxMs) {\n verdict ||= 'timeout';\n }\n }\n return verdict;\n }\n}\n"]}
1
+ {"version":3,"file":"vad.js","sourceRoot":"","sources":["../../../src/lib/voice/vad.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * End-of-speech detection for satellites that stream microphone audio until they are told to stop.\n *\n * The Hannah/UDP protocol has the satellite decide when an utterance ended (`audio_end`), and Wyoming\n * clients send `audio-stop` themselves. ESPHome voice satellites do neither: they keep streaming until\n * the server sends `STT_VAD_END`/`STT_END`, so the decision has to happen here.\n *\n * This is a plain energy gate, not a neural VAD — good enough to close an utterance, and it costs\n * nothing per frame. Speech must first rise above `startLevel`; after that, `silenceMs` worth of frames\n * below `endLevel` ends it. `maxMs` is the hard stop for rooms that are simply loud.\n *\n * Fixed thresholds only work when the microphone level is known, and on a real satellite it is not.\n * Measured on a ThirdReality Voice & Music Assistant: the device plays a wake-acknowledgement chirp\n * into its own microphone that peaks at full scale (RMS ~16000, clipped), while the speech that follows\n * arrives at RMS 358–469 — below the 700 default for `startLevel` and right on the 400 for `endLevel`.\n * That breaks the gate twice over: `sawSpeech` is satisfied by the chirp rather than by any voice, so\n * the \"nothing but speech-free audio\" guard never fires, and half the real speech frames count as\n * silence, so the utterance can be cut off mid-sentence.\n *\n * Hence two opt-in knobs, both enabled by the ESPHome transport:\n *\n * - `skipMs` drops the leading chirp from the *analysis* only. What reaches STT is cut later, from where\n * speech was detected minus a margin (`speechAudio.ts`) — a fast speaker may already be talking.\n * - `adaptive` derives the thresholds from a tracked noise floor instead of fixed levels. The floor\n * follows the quietest frame seen (instant down, very slow up), so it settles on room noise even when\n * someone starts talking immediately — which a mean or median over a calibration window would not.\n *\n * And one for longer requests: `longSilenceMs` after `longAfterMs` of speech. In a quiet room the silence\n * threshold sits at its minimum, and a voice a few metres away dips below it between syllables — on a\n * ThirdReality speaker a whole \"Мессенджер\" (0.7 s) stayed under 150. A pause for thought on top of that\n * ended a long request mid-sentence, while short commands should keep closing quickly.\n */\n\n/**\n * A buffer that may be a view onto any `ArrayBufferLike` — what `Buffer.subarray()` and\n * `Buffer.concat()` hand back. Plain `Buffer` (= `Buffer<ArrayBuffer>`) assigns to it, not the reverse.\n */\nexport type Bytes = Buffer<ArrayBufferLike>;\n\n/** Root-mean-square amplitude (0…32767) of a mono 16-bit signed LE PCM buffer. */\nexport function rms(pcm: Bytes): number {\n const samples = Math.floor(pcm.length / 2);\n if (!samples) {\n return 0;\n }\n let sum = 0;\n for (let i = 0; i < samples; i++) {\n const value = pcm.readInt16LE(i * 2);\n sum += value * value;\n }\n return Math.sqrt(sum / samples);\n}\n\nexport interface SpeechDetectorOptions {\n /** Sample rate of the incoming PCM (ESPHome satellites always capture at 16 kHz). */\n sampleRate?: number;\n /** Analysis window; 30 ms is the usual compromise between latency and stability. */\n frameMs?: number;\n /** RMS above which we consider speech to have started. Ignored when `adaptive` is on. */\n startLevel?: number;\n /** RMS below which a frame counts as silence. Ignored when `adaptive` is on. */\n endLevel?: number;\n /** Silence after speech that ends the utterance. */\n silenceMs?: number;\n /**\n * Silence that ends the utterance once the speaker has talked for {@link longAfterMs} (0 = always\n * {@link silenceMs}). Somebody dictating a longer request pauses to think, and a short command should\n * still close fast.\n */\n longSilenceMs?: number;\n /** Speech (from its start to the pause) after which {@link longSilenceMs} applies; 0 = never. */\n longAfterMs?: number;\n /**\n * Sound above the silence threshold needed before a crossing of the speech threshold counts as speech; 0 = the\n * first loud frame does. A click or a knock is over in 100 ms, the shortest \"да\" takes about 250.\n */\n minSpeechMs?: number;\n /** A candidate start that falls quiet this long before reaching {@link minSpeechMs} is forgotten. */\n speechGapMs?: number;\n /** Hard cap on one utterance, in case the room never falls quiet. */\n maxMs?: number;\n /**\n * Give up when no speech has started this long after the stream began (the skipped lead-in included);\n * 0 = only {@link maxMs}. A false wake would otherwise keep the microphone open for the whole `maxMs`.\n */\n startTimeoutMs?: number;\n /**\n * Leading audio to leave out of the analysis — for devices that chirp into their own microphone\n * when the wake word fires. The audio itself is untouched; this only stops the chirp from being\n * mistaken for speech and from poisoning the noise floor.\n */\n skipMs?: number;\n /** Derive the thresholds from the tracked noise floor instead of using fixed levels. */\n adaptive?: boolean;\n /** Adaptive mode: `startLevel` is at least this, however quiet the room is. */\n minStartLevel?: number;\n /** Adaptive mode: `endLevel` is at least this. */\n minEndLevel?: number;\n /** Adaptive mode: speech threshold as a multiple of the noise floor. */\n startFactor?: number;\n /** Adaptive mode: silence threshold as a multiple of the noise floor. */\n endFactor?: number;\n /** Adaptive mode: settle the noise floor this long before speech may be declared. */\n warmupMs?: number;\n}\n\n/** Why an utterance ended: the speaker stopped, we hit the hard cap, or nobody started speaking at all. */\nexport type SpeechEnd = 'speech-end' | 'timeout' | 'no-speech';\n\n/**\n * Why a recording ended, from the transport's view: what the detector said, the device ending the stream\n * ('stream'), the device going quiet without ending it ('stall'), or the transport's last-resort timer.\n */\nexport type CaptureEnd = SpeechEnd | 'stream' | 'stall' | 'pipeline-timeout';\n\n/** What the detector made of one recording — for the request trace and the diagnosis recordings. */\nexport interface VadReport {\n /** Loudest frame, the wake chirp included. */\n peak: number;\n /** Loudest frame after the skipped lead-in. */\n analysedPeak: number;\n noiseFloor: number;\n startLevel: number;\n endLevel: number;\n /** Where speech began, in ms from the start of the recording; absent when it never did. */\n speechStartMs?: number;\n /** Why the recording ended. */\n end?: CaptureEnd;\n /** Sound above the silence threshold since speech started, in ms. */\n voicedMs?: number;\n /** Silence that was needed to end it at that point (longer once the speaker talked for a while). */\n silenceMs?: number;\n /** RMS per 100 ms of the whole recording. */\n levels?: number[];\n /** What went to speech recognition: the recording cut to the speech, and the gain applied to it. */\n stt?: { fromMs: number; toMs: number; gain: number };\n}\n\n/**\n * How fast the tracked noise floor may follow a *rising* ambient level, per analysed frame (30 ms).\n * Fast enough that a genuinely noisy room is matched within about a second, slow enough that a voice\n * cannot pull the floor up to its own level before it has been recognised as speech.\n */\nconst NOISE_RISE = 0.02;\n\nexport class SpeechDetector {\n private readonly frameBytes: number;\n private readonly frameMs: number;\n private readonly fixedStart: number;\n private readonly fixedEnd: number;\n private readonly silenceMs: number;\n private readonly longSilenceMs: number;\n private readonly longAfterMs: number;\n private readonly minSpeechMs: number;\n private readonly speechGapMs: number;\n private readonly maxMs: number;\n private readonly startTimeoutMs: number;\n private readonly skipMs: number;\n private readonly adaptive: boolean;\n private readonly minStartLevel: number;\n private readonly minEndLevel: number;\n private readonly startFactor: number;\n private readonly endFactor: number;\n private readonly warmupMs: number;\n\n private pending: Bytes = Buffer.alloc(0);\n private speechSeen = false;\n private silentMs = 0;\n /** Tracked room level in adaptive mode; null until the first analysed frame. */\n private floor: number | null = null;\n /** Audio seen so far *including* the skipped lead-in, in milliseconds. */\n private elapsedMs = 0;\n /** Start of the frame that crossed {@link startLevel}, in ms from the start of the recording; -1 = not yet. */\n speechStartMs = -1;\n /** A crossing of {@link startLevel} that has not yet lasted {@link minSpeechMs}: its start, -1 = none. */\n private candidateMs = -1;\n /** Quiet since the last loud frame of the candidate. */\n private candidateQuietMs = 0;\n /** Sound above {@link endLevel} since the candidate (and then the speech) started, in ms. */\n voicedMs = 0;\n /** The longest sound that crossed {@link startLevel}, in ms — also one too short to count as speech. */\n longestSoundMs = 0;\n /** Audio analysed so far, in milliseconds (exposed for logging). */\n totalMs = 0;\n /** Loudest frame seen so far (exposed for logging — the usual clue when a mic is mis-configured). */\n peak = 0;\n /**\n * Loudest frame after the skipped lead-in, i.e. without the wake chirp. Compared with {@link startLevel}\n * it tells whether a dropped utterance was silence or a voice that was simply too quiet for the gate.\n */\n analysedPeak = 0;\n\n constructor({\n sampleRate = 16000,\n frameMs = 30,\n startLevel = 700,\n endLevel = 400,\n silenceMs = 900,\n longSilenceMs = 0,\n longAfterMs = 0,\n minSpeechMs = 0,\n speechGapMs = 300,\n maxMs = 12000,\n startTimeoutMs = 0,\n skipMs = 0,\n adaptive = false,\n minStartLevel = 250,\n minEndLevel = 150,\n startFactor = 3.5,\n endFactor = 2,\n warmupMs = 150,\n }: SpeechDetectorOptions = {}) {\n this.frameBytes = Math.round((sampleRate * frameMs) / 1000) * 2;\n this.frameMs = frameMs;\n this.fixedStart = startLevel;\n this.fixedEnd = endLevel;\n this.silenceMs = silenceMs;\n this.longSilenceMs = longSilenceMs;\n this.longAfterMs = longAfterMs;\n this.minSpeechMs = minSpeechMs;\n this.speechGapMs = speechGapMs;\n this.maxMs = maxMs;\n this.startTimeoutMs = startTimeoutMs;\n this.skipMs = skipMs;\n this.adaptive = adaptive;\n this.minStartLevel = minStartLevel;\n this.minEndLevel = minEndLevel;\n this.startFactor = startFactor;\n this.endFactor = endFactor;\n this.warmupMs = warmupMs;\n }\n\n /** True once any frame crossed {@link startLevel} — an utterance that never did is room noise. */\n get sawSpeech(): boolean {\n return this.speechSeen;\n }\n\n /** Tracked room level (adaptive mode), for logging. 0 before the first analysed frame. */\n get noiseFloor(): number {\n return this.floor ?? 0;\n }\n\n /** Current speech threshold — fixed, or derived from the noise floor in adaptive mode. */\n get startLevel(): number {\n return this.adaptive ? Math.max(this.minStartLevel, (this.floor ?? 0) * this.startFactor) : this.fixedStart;\n }\n\n /** Current silence threshold. Never above {@link startLevel}, so the gate keeps its hysteresis. */\n get endLevel(): number {\n if (!this.adaptive) {\n return this.fixedEnd;\n }\n return Math.min(this.startLevel, Math.max(this.minEndLevel, (this.floor ?? 0) * this.endFactor));\n }\n\n /**\n * Silence that ends the utterance right now: {@link silenceMs}, or {@link longSilenceMs} once the speaker\n * has talked for {@link longAfterMs} before the current pause.\n */\n get requiredSilenceMs(): number {\n if (!this.speechSeen || !this.longAfterMs || this.longSilenceMs <= this.silenceMs) {\n return this.silenceMs;\n }\n const spokenMs = this.elapsedMs - this.silentMs - this.speechStartMs;\n return spokenMs >= this.longAfterMs ? this.longSilenceMs : this.silenceMs;\n }\n\n /** The detector's view of the recording so far. */\n report(): VadReport {\n return {\n peak: Math.round(this.peak),\n analysedPeak: Math.round(this.analysedPeak),\n noiseFloor: Math.round(this.noiseFloor),\n startLevel: Math.round(this.startLevel),\n endLevel: Math.round(this.endLevel),\n speechStartMs: this.speechSeen ? this.speechStartMs : undefined,\n voicedMs: this.speechSeen ? this.voicedMs : undefined,\n silenceMs: this.requiredSilenceMs,\n };\n }\n\n /** Feed one audio chunk (any size). Returns why the utterance ended, or null to keep listening. */\n push(chunk: Bytes): SpeechEnd | null {\n this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;\n let verdict: SpeechEnd | null = null;\n\n while (this.pending.length >= this.frameBytes) {\n const frame = this.pending.subarray(0, this.frameBytes);\n this.pending = this.pending.subarray(this.frameBytes);\n this.elapsedMs += this.frameMs;\n\n const level = rms(frame);\n if (level > this.peak) {\n this.peak = level;\n }\n\n // The device's own wake chirp: loud, clipped, and not speech. Leaving it out of the\n // analysis is what makes `sawSpeech` mean anything, and keeps the noise floor honest.\n if (this.elapsedMs <= this.skipMs) {\n continue;\n }\n this.totalMs += this.frameMs;\n if (level > this.analysedPeak) {\n this.analysedPeak = level;\n }\n\n if (this.adaptive) {\n // Three rules, each there to defeat a specific way of getting this wrong:\n // - start at zero, i.e. maximally sensitive. Seeding from the first frame instead\n // lets someone who talks straight after the wake word install their own voice as\n // the room's noise floor, which shuts the gate for good — the same trap an average\n // or median over a calibration window falls into.\n // - fall instantly, so a room that went quiet is believed at once.\n // - rise slowly, and not at all once speech has been recognised: otherwise a long\n // utterance drags the floor, and with it `endLevel`, up to its own level and cuts\n // itself off mid-sentence. Falling stays on, so the pauses between words still count.\n this.floor =\n this.floor === null\n ? 0\n : level < this.floor\n ? level\n : this.speechSeen || this.candidateMs >= 0\n ? this.floor\n : this.floor + (level - this.floor) * NOISE_RISE;\n if (this.totalMs < this.warmupMs) {\n continue; // let the floor settle before anything may count as speech\n }\n }\n\n if (!this.speechSeen) {\n // A loud frame opens a candidate; it becomes speech once it has lasted `minSpeechMs`, and is\n // forgotten when it falls quiet for `speechGapMs` before that — a click is not a request.\n if (this.candidateMs < 0) {\n if (level >= this.startLevel) {\n this.candidateMs = this.elapsedMs - this.frameMs;\n this.candidateQuietMs = 0;\n this.voicedMs = this.frameMs;\n this.longestSoundMs = Math.max(this.longestSoundMs, this.voicedMs);\n }\n } else if (level >= this.endLevel) {\n this.voicedMs += this.frameMs;\n this.candidateQuietMs = 0;\n this.longestSoundMs = Math.max(this.longestSoundMs, this.voicedMs);\n } else {\n this.candidateQuietMs += this.frameMs;\n if (this.candidateQuietMs >= this.speechGapMs) {\n this.candidateMs = -1;\n this.voicedMs = 0;\n }\n }\n if (this.candidateMs >= 0 && this.voicedMs >= this.minSpeechMs) {\n this.speechSeen = true;\n this.speechStartMs = this.candidateMs;\n // A gap inside the candidate already counts towards the end of the utterance.\n this.silentMs = this.candidateQuietMs;\n }\n } else if (level < this.endLevel) {\n this.silentMs += this.frameMs;\n if (this.silentMs >= this.requiredSilenceMs) {\n verdict ||= 'speech-end';\n }\n } else {\n this.silentMs = 0;\n this.voicedMs += this.frameMs;\n }\n\n if (!this.speechSeen && this.startTimeoutMs > 0 && this.elapsedMs >= this.startTimeoutMs) {\n verdict ||= 'no-speech';\n }\n if (this.totalMs >= this.maxMs) {\n verdict ||= 'timeout';\n }\n }\n return verdict;\n }\n}\n"]}