iobroker.assistant 1.4.0 → 1.4.2
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-CCl7INIF.js +2 -0
- package/admin/custom/customComponents.js +1 -1
- 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 +2 -2
- package/admin/i18n/en.json +2 -2
- package/admin/i18n/es.json +2 -2
- package/admin/i18n/fr.json +2 -2
- package/admin/i18n/it.json +2 -2
- package/admin/i18n/nl.json +2 -2
- package/admin/i18n/pl.json +2 -2
- package/admin/i18n/pt.json +2 -2
- package/admin/i18n/ru.json +2 -2
- package/admin/i18n/uk.json +2 -2
- package/admin/i18n/zh-cn.json +2 -2
- package/admin/jsonConfig.json +2 -2
- package/build/lib/journal.js.map +1 -1
- package/build/lib/voice/captures.js.map +1 -1
- package/build/lib/voice/esphome.js +36 -24
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/esphomeEntities.js +19 -5
- package/build/lib/voice/esphomeEntities.js.map +1 -1
- package/build/lib/voice/fallback.js +18 -3
- package/build/lib/voice/fallback.js.map +1 -1
- package/build/lib/voice/speechAudio.js +97 -0
- package/build/lib/voice/speechAudio.js.map +1 -0
- package/build/lib/voice/vad.js +39 -4
- package/build/lib/voice/vad.js.map +1 -1
- package/build/main.js +26 -1
- package/build/main.js.map +1 -1
- package/io-package.json +27 -27
- package/package.json +1 -1
- package/admin/custom/assets/Components-D15eOzOp.js +0 -2
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.FallbackTts = exports.FallbackStt = void 0;
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Run `attempt` over the engines in order; the last error survives if all of them fail. A result that
|
|
6
|
+
* `useful` rejects also moves on to the next engine; if none does better, the first such result is returned.
|
|
7
|
+
*/
|
|
8
|
+
async function overEngines(engines, what, log, attempt, useful) {
|
|
6
9
|
let last;
|
|
10
|
+
let unused;
|
|
7
11
|
for (const [index, entry] of engines.entries()) {
|
|
8
12
|
try {
|
|
9
13
|
const result = await attempt(entry.engine);
|
|
14
|
+
const next = engines[index + 1];
|
|
15
|
+
if (useful && !useful(result)) {
|
|
16
|
+
unused ??= { result };
|
|
17
|
+
if (next) {
|
|
18
|
+
log.info(`${what}: ${entry.name} returned nothing — trying ${next.name}.`);
|
|
19
|
+
}
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
10
22
|
if (index > 0) {
|
|
11
23
|
log.info(`${what}: answered by the fallback engine (${entry.name}).`);
|
|
12
24
|
}
|
|
@@ -18,6 +30,9 @@ async function overEngines(engines, what, log, attempt) {
|
|
|
18
30
|
log[next ? 'warn' : 'error'](`${what}: ${entry.name} failed (${last.message})${next ? ` — trying ${next.name}` : ''}.`);
|
|
19
31
|
}
|
|
20
32
|
}
|
|
33
|
+
if (unused) {
|
|
34
|
+
return unused.result;
|
|
35
|
+
}
|
|
21
36
|
throw last ?? new Error(`${what}: no engine configured`);
|
|
22
37
|
}
|
|
23
38
|
/** Speech-to-text with one fallback engine. */
|
|
@@ -29,7 +44,7 @@ class FallbackStt {
|
|
|
29
44
|
this.log = log;
|
|
30
45
|
}
|
|
31
46
|
transcribe(pcm, sampleRate, lang, hints) {
|
|
32
|
-
return overEngines(this.engines, 'Speech-to-text', this.log, e => e.transcribe(pcm, sampleRate, lang, hints));
|
|
47
|
+
return overEngines(this.engines, 'Speech-to-text', this.log, e => e.transcribe(pcm, sampleRate, lang, hints), text => !!text.trim());
|
|
33
48
|
}
|
|
34
49
|
/** Prepare every engine: the fallback is useless if its model is only downloaded once it is needed. */
|
|
35
50
|
async prepare(lang) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fallback.js","sourceRoot":"","sources":["../../../src/lib/voice/fallback.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"fallback.js","sourceRoot":"","sources":["../../../src/lib/voice/fallback.ts"],"names":[],"mappings":";;;AA8BA;;;GAGG;AACH,KAAK,UAAU,WAAW,CACtB,OAAyB,EACzB,IAAY,EACZ,GAAgB,EAChB,OAAkC,EAClC,MAA+B;IAE/B,IAAI,IAAuB,CAAC;IAC5B,IAAI,MAAiC,CAAC;IACtC,KAAK,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;QAC7C,IAAI,CAAC;YACD,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;YAC3C,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;YAChC,IAAI,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC5B,MAAM,KAAK,EAAE,MAAM,EAAE,CAAC;gBACtB,IAAI,IAAI,EAAE,CAAC;oBACP,GAAG,CAAC,IAAI,CAAC,GAAG,IAAI,KAAK,KAAK,CAAC,IAAI,8BAA8B,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC;gBAC/E,CAAC;gBACD,SAAS;YACb,CAAC;YACD,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;gBACZ,GAAG,CAAC,IAAI,CAAC,GAAG,IAAI,sCAAsC,KAAK,CAAC,IAAI,IAAI,CAAC,CAAC;YAC1E,CAAC;YACD,OAAO,MAAM,CAAC;QAClB,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACT,IAAI,GAAG,CAAU,CAAC;YAClB,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;YAChC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CACxB,GAAG,IAAI,KAAK,KAAK,CAAC,IAAI,YAAY,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,CAAC,CAAC,aAAa,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,CAC5F,CAAC;QACN,CAAC;IACL,CAAC;IACD,IAAI,MAAM,EAAE,CAAC;QACT,OAAO,MAAM,CAAC,MAAM,CAAC;IACzB,CAAC;IACD,MAAM,IAAI,IAAI,IAAI,KAAK,CAAC,GAAG,IAAI,wBAAwB,CAAC,CAAC;AAC7D,CAAC;AAED,+CAA+C;AAC/C,MAAa,WAAW;IAEC;IACA;IAFrB,YACqB,OAAiC,EACjC,GAAgB;QADhB,YAAO,GAAP,OAAO,CAA0B;QACjC,QAAG,GAAH,GAAG,CAAa;IAClC,CAAC;IAEJ,UAAU,CAAC,GAAW,EAAE,UAAkB,EAAE,IAAY,EAAE,KAAgB;QACtE,OAAO,WAAW,CACd,IAAI,CAAC,OAAO,EACZ,gBAAgB,EAChB,IAAI,CAAC,GAAG,EACR,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,KAAK,CAAC,EAC/C,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,CACxB,CAAC;IACN,CAAC;IAED,uGAAuG;IACvG,KAAK,CAAC,OAAO,CAAC,IAAY;QACtB,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YAC/B,IAAI,CAAC;gBACD,MAAM,KAAK,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC;YACvC,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACT,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,6BAA6B,KAAK,CAAC,IAAI,YAAa,CAAW,CAAC,OAAO,IAAI,CAAC,CAAC;YAC/F,CAAC;QACL,CAAC;IACL,CAAC;CACJ;AA1BD,kCA0BC;AAED,+CAA+C;AAC/C,MAAa,WAAW;IAEC;IACA;IAFrB,YACqB,OAAiC,EACjC,GAAgB;QADhB,YAAO,GAAP,OAAO,CAA0B;QACjC,QAAG,GAAH,GAAG,CAAa;IAClC,CAAC;IAEJ,UAAU,CAAC,IAAY,EAAE,IAAY;QACjC,OAAO,WAAW,CAAC,IAAI,CAAC,OAAO,EAAE,gBAAgB,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;IAChG,CAAC;IAED;;;OAGG;IACH,cAAc,CAAC,IAAY,EAAE,IAAY;QACrC,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;QACpE,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;YAClB,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACvC,CAAC;QACD,OAAO,WAAW,CAAC,OAAO,EAAE,uBAAuB,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,cAAe,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;IACvG,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,IAAY;QACtB,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YAC/B,IAAI,CAAC;gBACD,MAAM,KAAK,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC;YACvC,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACT,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,6BAA6B,KAAK,CAAC,IAAI,YAAa,CAAW,CAAC,OAAO,IAAI,CAAC,CAAC;YAC/F,CAAC;QACL,CAAC;IACL,CAAC;IAED,8FAA8F;IAC9F,IAAI,CAAC,OAAiB,EAAE,IAAY;QAChC,OAAO,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC/E,CAAC;CACJ;AApCD,kCAoCC","sourcesContent":["/**\n * A second speech engine behind the first one, so an outage does not take the voice away.\n *\n * The usual setup is a cloud engine with a local one behind it: when OpenAI/Azure/AWS is unreachable, the\n * API key expired or the quota ran out, Vosk and Piper keep the house answering — worse sounding, but\n * answering. That is the whole value: without it, the assistant goes mute exactly when the internet does.\n *\n * The idea comes from the Python original (`C:\\iot\\Hannah`, `core/hannah/stt.py`, which chains\n * aws → azure → remote → local); here it is one explicit fallback rather than an open chain, because a\n * household configures at most two speech providers and a longer chain would mostly be a way to wait\n * several timeouts in a row.\n *\n * Any failure of the primary moves on to the fallback: the alternative would be classifying provider\n * errors into \"retryable\" and \"not\", which is guesswork across three SDKs — and a request the primary\n * cannot answer is one the user still wants answered.\n *\n * Speech-to-text also moves on when the primary returns no text at all. A satellite only sends audio it took\n * for speech, and models differ in what they make of it: Nemotron returned nothing for a quiet\n * \"Мессенджер. Да, да.\" that Whisper recognised. The price is a second request for a false wake.\n */\nimport type { SttEngine } from './stt';\nimport type { TtsEngine, TtsResult } from './tts';\nimport type { VoiceLogger } from './download';\n\n/** One named engine in the chain. */\nexport interface NamedEngine<T> {\n name: string;\n engine: T;\n}\n\n/**\n * Run `attempt` over the engines in order; the last error survives if all of them fail. A result that\n * `useful` rejects also moves on to the next engine; if none does better, the first such result is returned.\n */\nasync function overEngines<T, R>(\n engines: NamedEngine<T>[],\n what: string,\n log: VoiceLogger,\n attempt: (engine: T) => Promise<R>,\n useful?: (result: R) => boolean,\n): Promise<R> {\n let last: Error | undefined;\n let unused: { result: R } | undefined;\n for (const [index, entry] of engines.entries()) {\n try {\n const result = await attempt(entry.engine);\n const next = engines[index + 1];\n if (useful && !useful(result)) {\n unused ??= { result };\n if (next) {\n log.info(`${what}: ${entry.name} returned nothing — trying ${next.name}.`);\n }\n continue;\n }\n if (index > 0) {\n log.info(`${what}: answered by the fallback engine (${entry.name}).`);\n }\n return result;\n } catch (e) {\n last = e as Error;\n const next = engines[index + 1];\n log[next ? 'warn' : 'error'](\n `${what}: ${entry.name} failed (${last.message})${next ? ` — trying ${next.name}` : ''}.`,\n );\n }\n }\n if (unused) {\n return unused.result;\n }\n throw last ?? new Error(`${what}: no engine configured`);\n}\n\n/** Speech-to-text with one fallback engine. */\nexport class FallbackStt implements SttEngine {\n constructor(\n private readonly engines: NamedEngine<SttEngine>[],\n private readonly log: VoiceLogger,\n ) {}\n\n transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string> {\n return overEngines(\n this.engines,\n 'Speech-to-text',\n this.log,\n e => e.transcribe(pcm, sampleRate, lang, hints),\n text => !!text.trim(),\n );\n }\n\n /** Prepare every engine: the fallback is useless if its model is only downloaded once it is needed. */\n async prepare(lang: string): Promise<void> {\n for (const entry of this.engines) {\n try {\n await entry.engine.prepare?.(lang);\n } catch (e) {\n this.log.warn(`Speech-to-text: preparing ${entry.name} failed (${(e as Error).message}).`);\n }\n }\n }\n}\n\n/** Text-to-speech with one fallback engine. */\nexport class FallbackTts implements TtsEngine {\n constructor(\n private readonly engines: NamedEngine<TtsEngine>[],\n private readonly log: VoiceLogger,\n ) {}\n\n synthesize(text: string, lang: string): Promise<TtsResult> {\n return overEngines(this.engines, 'Text-to-speech', this.log, e => e.synthesize(text, lang));\n }\n\n /**\n * SSML only goes to engines that can speak it; if none can, the primary's plain-text path handles it\n * (its cache wrapper strips the markup).\n */\n synthesizeSsml(ssml: string, lang: string): Promise<TtsResult> {\n const capable = this.engines.filter(e => !!e.engine.synthesizeSsml);\n if (!capable.length) {\n return this.synthesize(ssml, lang);\n }\n return overEngines(capable, 'Text-to-speech (SSML)', this.log, e => e.synthesizeSsml!(ssml, lang));\n }\n\n async prepare(lang: string): Promise<void> {\n for (const entry of this.engines) {\n try {\n await entry.engine.prepare?.(lang);\n } catch (e) {\n this.log.warn(`Text-to-speech: preparing ${entry.name} failed (${(e as Error).message}).`);\n }\n }\n }\n\n /** Warm only the primary's cache: the fallback is for an outage, not for everyday phrases. */\n warm(phrases: string[], lang: string): Promise<number> {\n return this.engines[0]?.engine.warm?.(phrases, lang) ?? Promise.resolve(0);\n }\n}\n"]}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_GAIN = exports.TARGET_PEAK = exports.TAIL_SHARE = exports.TAIL_MS = exports.PRE_ROLL_MS = void 0;
|
|
4
|
+
exports.prepareForStt = prepareForStt;
|
|
5
|
+
/**
|
|
6
|
+
* Audio for speech recognition: the recording cut to the speech and brought to a usable level.
|
|
7
|
+
*
|
|
8
|
+
* ESPHome satellites stream from the wake word on, so a recording starts with the device's own chirp or beep
|
|
9
|
+
* and the silence before anybody speaks, and ends with the silence that told us the speaker stopped. Measured
|
|
10
|
+
* on a ThirdReality speaker (2026-11-03): the answer to a follow-up question — a 250 ms beep, a second of
|
|
11
|
+
* digital silence, then "Мессенджер. Да, да." at RMS 150–500 — came back empty from Nemotron, and Whisper
|
|
12
|
+
* made "Продолжение следует... Мессенджет. и я" of it (the subtitle line Whisper invents for silence). Cut to
|
|
13
|
+
* the speech and amplified, Whisper heard it right.
|
|
14
|
+
*
|
|
15
|
+
* Only what goes to the recognition changes; the diagnosis recordings keep the audio as it arrived.
|
|
16
|
+
*/
|
|
17
|
+
const vad_1 = require("./vad");
|
|
18
|
+
/** Audio kept before the frame where speech was detected: the soft start of a word lies below the gate. */
|
|
19
|
+
exports.PRE_ROLL_MS = 400;
|
|
20
|
+
/** Audio kept after the last sound: the fading end of a word. */
|
|
21
|
+
exports.TAIL_MS = 400;
|
|
22
|
+
/**
|
|
23
|
+
* A frame in the tail counts as sound when it reaches this share of the silence threshold — low, so that a
|
|
24
|
+
* quiet last syllable stays in. In a noisy room nothing is quiet enough, and the tail is simply kept.
|
|
25
|
+
*/
|
|
26
|
+
exports.TAIL_SHARE = 0.4;
|
|
27
|
+
/** Peak sample quiet speech is raised to — about half of full scale, headroom for the loudest syllable. */
|
|
28
|
+
exports.TARGET_PEAK = 16000;
|
|
29
|
+
/** Never amplify more than this: beyond it only the noise grows. */
|
|
30
|
+
exports.MAX_GAIN = 10;
|
|
31
|
+
/** Gains below this are not worth touching the audio for. */
|
|
32
|
+
const MIN_GAIN = 1.25;
|
|
33
|
+
/** Never cut a recording shorter than this — a short "да" plus its margins. */
|
|
34
|
+
const MIN_KEEP_MS = 600;
|
|
35
|
+
/** Resolution of the tail search. */
|
|
36
|
+
const FRAME_MS = 30;
|
|
37
|
+
/**
|
|
38
|
+
* Cut `pcm` (mono 16-bit) to the speech and amplify it when it is quiet.
|
|
39
|
+
*
|
|
40
|
+
* @param pcm the whole recording
|
|
41
|
+
* @param sampleRate its sample rate
|
|
42
|
+
* @param speech what the detector saw
|
|
43
|
+
* @param speech.startMs where speech started, in ms (absent = keep the beginning)
|
|
44
|
+
* @param speech.endLevel the silence threshold; the tail keeps everything above 40 % of it
|
|
45
|
+
*/
|
|
46
|
+
function prepareForStt(pcm, sampleRate, speech) {
|
|
47
|
+
const samples = Math.floor(pcm.length / 2);
|
|
48
|
+
const perMs = sampleRate / 1000;
|
|
49
|
+
const totalMs = Math.floor(samples / perMs);
|
|
50
|
+
let fromMs = speech.startMs !== undefined && speech.startMs >= 0 ? Math.max(0, speech.startMs - exports.PRE_ROLL_MS) : 0;
|
|
51
|
+
fromMs = Math.min(fromMs, Math.max(0, totalMs - MIN_KEEP_MS));
|
|
52
|
+
// The tail: the last frame with any sound in it, plus a margin.
|
|
53
|
+
let toMs = totalMs;
|
|
54
|
+
const quiet = speech.endLevel * exports.TAIL_SHARE;
|
|
55
|
+
if (quiet > 0) {
|
|
56
|
+
const frame = Math.max(1, Math.round(FRAME_MS * perMs));
|
|
57
|
+
const first = Math.round(fromMs * perMs);
|
|
58
|
+
let lastSound = -1;
|
|
59
|
+
for (let start = samples - frame; start >= first; start -= frame) {
|
|
60
|
+
if ((0, vad_1.rms)(pcm.subarray(start * 2, (start + frame) * 2)) >= quiet) {
|
|
61
|
+
lastSound = start + frame;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (lastSound >= 0) {
|
|
66
|
+
toMs = Math.min(totalMs, Math.ceil(lastSound / perMs) + exports.TAIL_MS);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (toMs - fromMs < MIN_KEEP_MS) {
|
|
70
|
+
toMs = Math.min(totalMs, fromMs + MIN_KEEP_MS);
|
|
71
|
+
}
|
|
72
|
+
const from = Math.round(fromMs * perMs);
|
|
73
|
+
const to = Math.min(samples, Math.round(toMs * perMs));
|
|
74
|
+
let peak = 0;
|
|
75
|
+
for (let i = from; i < to; i++) {
|
|
76
|
+
const value = Math.abs(pcm.readInt16LE(i * 2));
|
|
77
|
+
if (value > peak) {
|
|
78
|
+
peak = value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
let gain = peak > 0 ? Math.min(exports.MAX_GAIN, exports.TARGET_PEAK / peak) : 1;
|
|
82
|
+
if (gain < MIN_GAIN) {
|
|
83
|
+
gain = 1;
|
|
84
|
+
}
|
|
85
|
+
const out = Buffer.alloc((to - from) * 2);
|
|
86
|
+
if (gain === 1) {
|
|
87
|
+
pcm.copy(out, 0, from * 2, to * 2);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
for (let i = from; i < to; i++) {
|
|
91
|
+
const value = Math.round(pcm.readInt16LE(i * 2) * gain);
|
|
92
|
+
out.writeInt16LE(Math.max(-32768, Math.min(32767, value)), (i - from) * 2);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { pcm: out, fromMs, toMs, gain: Math.round(gain * 100) / 100 };
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=speechAudio.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"speechAudio.js","sourceRoot":"","sources":["../../../src/lib/voice/speechAudio.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Audio for speech recognition: the recording cut to the speech and brought to a usable level.\n *\n * ESPHome satellites stream from the wake word on, so a recording starts with the device's own chirp or beep\n * and the silence before anybody speaks, and ends with the silence that told us the speaker stopped. Measured\n * on a ThirdReality speaker (2026-11-03): the answer to a follow-up question — a 250 ms beep, a second of\n * digital silence, then \"Мессенджер. Да, да.\" at RMS 150–500 — came back empty from Nemotron, and Whisper\n * made \"Продолжение следует... Мессенджет. и я\" of it (the subtitle line Whisper invents for silence). Cut to\n * the speech and amplified, Whisper heard it right.\n *\n * Only what goes to the recognition changes; the diagnosis recordings keep the audio as it arrived.\n */\nimport { rms, type Bytes } from './vad';\n\n/** Audio kept before the frame where speech was detected: the soft start of a word lies below the gate. */\nexport const PRE_ROLL_MS = 400;\n/** Audio kept after the last sound: the fading end of a word. */\nexport const TAIL_MS = 400;\n/**\n * A frame in the tail counts as sound when it reaches this share of the silence threshold — low, so that a\n * quiet last syllable stays in. In a noisy room nothing is quiet enough, and the tail is simply kept.\n */\nexport const TAIL_SHARE = 0.4;\n/** Peak sample quiet speech is raised to — about half of full scale, headroom for the loudest syllable. */\nexport const TARGET_PEAK = 16000;\n/** Never amplify more than this: beyond it only the noise grows. */\nexport const MAX_GAIN = 10;\n/** Gains below this are not worth touching the audio for. */\nconst MIN_GAIN = 1.25;\n/** Never cut a recording shorter than this — a short \"да\" plus its margins. */\nconst MIN_KEEP_MS = 600;\n/** Resolution of the tail search. */\nconst FRAME_MS = 30;\n\nexport interface SttAudio {\n /** The audio to recognise. */\n pcm: Buffer;\n /** Where it starts and ends in the recording, in ms. */\n fromMs: number;\n toMs: number;\n /** Gain applied to it (1 = unchanged), rounded to two decimals. */\n gain: number;\n}\n\n/**\n * Cut `pcm` (mono 16-bit) to the speech and amplify it when it is quiet.\n *\n * @param pcm the whole recording\n * @param sampleRate its sample rate\n * @param speech what the detector saw\n * @param speech.startMs where speech started, in ms (absent = keep the beginning)\n * @param speech.endLevel the silence threshold; the tail keeps everything above 40 % of it\n */\nexport function prepareForStt(\n pcm: Bytes,\n sampleRate: number,\n speech: { startMs?: number; endLevel: number },\n): SttAudio {\n const samples = Math.floor(pcm.length / 2);\n const perMs = sampleRate / 1000;\n const totalMs = Math.floor(samples / perMs);\n\n let fromMs = speech.startMs !== undefined && speech.startMs >= 0 ? Math.max(0, speech.startMs - PRE_ROLL_MS) : 0;\n fromMs = Math.min(fromMs, Math.max(0, totalMs - MIN_KEEP_MS));\n\n // The tail: the last frame with any sound in it, plus a margin.\n let toMs = totalMs;\n const quiet = speech.endLevel * TAIL_SHARE;\n if (quiet > 0) {\n const frame = Math.max(1, Math.round(FRAME_MS * perMs));\n const first = Math.round(fromMs * perMs);\n let lastSound = -1;\n for (let start = samples - frame; start >= first; start -= frame) {\n if (rms(pcm.subarray(start * 2, (start + frame) * 2)) >= quiet) {\n lastSound = start + frame;\n break;\n }\n }\n if (lastSound >= 0) {\n toMs = Math.min(totalMs, Math.ceil(lastSound / perMs) + TAIL_MS);\n }\n }\n if (toMs - fromMs < MIN_KEEP_MS) {\n toMs = Math.min(totalMs, fromMs + MIN_KEEP_MS);\n }\n\n const from = Math.round(fromMs * perMs);\n const to = Math.min(samples, Math.round(toMs * perMs));\n let peak = 0;\n for (let i = from; i < to; i++) {\n const value = Math.abs(pcm.readInt16LE(i * 2));\n if (value > peak) {\n peak = value;\n }\n }\n let gain = peak > 0 ? Math.min(MAX_GAIN, TARGET_PEAK / peak) : 1;\n if (gain < MIN_GAIN) {\n gain = 1;\n }\n\n const out = Buffer.alloc((to - from) * 2);\n if (gain === 1) {\n pcm.copy(out, 0, from * 2, to * 2);\n } else {\n for (let i = from; i < to; i++) {\n const value = Math.round(pcm.readInt16LE(i * 2) * gain);\n out.writeInt16LE(Math.max(-32768, Math.min(32767, value)), (i - from) * 2);\n }\n }\n return { pcm: out, fromMs, toMs, gain: Math.round(gain * 100) / 100 };\n}\n"]}
|
package/build/lib/voice/vad.js
CHANGED
|
@@ -20,11 +20,16 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Hence two opt-in knobs, both enabled by the ESPHome transport:
|
|
22
22
|
*
|
|
23
|
-
* - `skipMs` drops the leading chirp from the *analysis*
|
|
24
|
-
*
|
|
23
|
+
* - `skipMs` drops the leading chirp from the *analysis* only. What reaches STT is cut later, from where
|
|
24
|
+
* speech was detected minus a margin (`speechAudio.ts`) — a fast speaker may already be talking.
|
|
25
25
|
* - `adaptive` derives the thresholds from a tracked noise floor instead of fixed levels. The floor
|
|
26
26
|
* follows the quietest frame seen (instant down, very slow up), so it settles on room noise even when
|
|
27
27
|
* someone starts talking immediately — which a mean or median over a calibration window would not.
|
|
28
|
+
*
|
|
29
|
+
* And one for longer requests: `longSilenceMs` after `longAfterMs` of speech. In a quiet room the silence
|
|
30
|
+
* threshold sits at its minimum, and a voice a few metres away dips below it between syllables — on a
|
|
31
|
+
* ThirdReality speaker a whole "Мессенджер" (0.7 s) stayed under 150. A pause for thought on top of that
|
|
32
|
+
* ended a long request mid-sentence, while short commands should keep closing quickly.
|
|
28
33
|
*/
|
|
29
34
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
35
|
exports.SpeechDetector = void 0;
|
|
@@ -54,6 +59,8 @@ class SpeechDetector {
|
|
|
54
59
|
fixedStart;
|
|
55
60
|
fixedEnd;
|
|
56
61
|
silenceMs;
|
|
62
|
+
longSilenceMs;
|
|
63
|
+
longAfterMs;
|
|
57
64
|
maxMs;
|
|
58
65
|
startTimeoutMs;
|
|
59
66
|
skipMs;
|
|
@@ -70,6 +77,8 @@ class SpeechDetector {
|
|
|
70
77
|
floor = null;
|
|
71
78
|
/** Audio seen so far *including* the skipped lead-in, in milliseconds. */
|
|
72
79
|
elapsedMs = 0;
|
|
80
|
+
/** Start of the frame that crossed {@link startLevel}, in ms from the start of the recording; -1 = not yet. */
|
|
81
|
+
speechStartMs = -1;
|
|
73
82
|
/** Audio analysed so far, in milliseconds (exposed for logging). */
|
|
74
83
|
totalMs = 0;
|
|
75
84
|
/** Loudest frame seen so far (exposed for logging — the usual clue when a mic is mis-configured). */
|
|
@@ -79,12 +88,14 @@ class SpeechDetector {
|
|
|
79
88
|
* it tells whether a dropped utterance was silence or a voice that was simply too quiet for the gate.
|
|
80
89
|
*/
|
|
81
90
|
analysedPeak = 0;
|
|
82
|
-
constructor({ sampleRate = 16000, frameMs = 30, startLevel = 700, endLevel = 400, silenceMs = 900, maxMs = 12000, startTimeoutMs = 0, skipMs = 0, adaptive = false, minStartLevel = 250, minEndLevel = 150, startFactor = 3.5, endFactor = 2, warmupMs = 150, } = {}) {
|
|
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, } = {}) {
|
|
83
92
|
this.frameBytes = Math.round((sampleRate * frameMs) / 1000) * 2;
|
|
84
93
|
this.frameMs = frameMs;
|
|
85
94
|
this.fixedStart = startLevel;
|
|
86
95
|
this.fixedEnd = endLevel;
|
|
87
96
|
this.silenceMs = silenceMs;
|
|
97
|
+
this.longSilenceMs = longSilenceMs;
|
|
98
|
+
this.longAfterMs = longAfterMs;
|
|
88
99
|
this.maxMs = maxMs;
|
|
89
100
|
this.startTimeoutMs = startTimeoutMs;
|
|
90
101
|
this.skipMs = skipMs;
|
|
@@ -114,6 +125,29 @@ class SpeechDetector {
|
|
|
114
125
|
}
|
|
115
126
|
return Math.min(this.startLevel, Math.max(this.minEndLevel, (this.floor ?? 0) * this.endFactor));
|
|
116
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Silence that ends the utterance right now: {@link silenceMs}, or {@link longSilenceMs} once the speaker
|
|
130
|
+
* has talked for {@link longAfterMs} before the current pause.
|
|
131
|
+
*/
|
|
132
|
+
get requiredSilenceMs() {
|
|
133
|
+
if (!this.speechSeen || !this.longAfterMs || this.longSilenceMs <= this.silenceMs) {
|
|
134
|
+
return this.silenceMs;
|
|
135
|
+
}
|
|
136
|
+
const spokenMs = this.elapsedMs - this.silentMs - this.speechStartMs;
|
|
137
|
+
return spokenMs >= this.longAfterMs ? this.longSilenceMs : this.silenceMs;
|
|
138
|
+
}
|
|
139
|
+
/** The detector's view of the recording so far. */
|
|
140
|
+
report() {
|
|
141
|
+
return {
|
|
142
|
+
peak: Math.round(this.peak),
|
|
143
|
+
analysedPeak: Math.round(this.analysedPeak),
|
|
144
|
+
noiseFloor: Math.round(this.noiseFloor),
|
|
145
|
+
startLevel: Math.round(this.startLevel),
|
|
146
|
+
endLevel: Math.round(this.endLevel),
|
|
147
|
+
speechStartMs: this.speechSeen ? this.speechStartMs : undefined,
|
|
148
|
+
silenceMs: this.requiredSilenceMs,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
117
151
|
/** Feed one audio chunk (any size). Returns why the utterance ended, or null to keep listening. */
|
|
118
152
|
push(chunk) {
|
|
119
153
|
this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;
|
|
@@ -160,12 +194,13 @@ class SpeechDetector {
|
|
|
160
194
|
if (!this.speechSeen) {
|
|
161
195
|
if (level >= this.startLevel) {
|
|
162
196
|
this.speechSeen = true;
|
|
197
|
+
this.speechStartMs = this.elapsedMs - this.frameMs;
|
|
163
198
|
this.silentMs = 0;
|
|
164
199
|
}
|
|
165
200
|
}
|
|
166
201
|
else if (level < this.endLevel) {
|
|
167
202
|
this.silentMs += this.frameMs;
|
|
168
|
-
if (this.silentMs >= this.
|
|
203
|
+
if (this.silentMs >= this.requiredSilenceMs) {
|
|
169
204
|
verdict ||= 'speech-end';
|
|
170
205
|
}
|
|
171
206
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vad.js","sourceRoot":"","sources":["../../../src/lib/voice/vad.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * End-of-speech detection for satellites that stream microphone audio until they are told to stop.\n *\n * The Hannah/UDP protocol has the satellite decide when an utterance ended (`audio_end`), and Wyoming\n * clients send `audio-stop` themselves. ESPHome voice satellites do neither: they keep streaming until\n * the server sends `STT_VAD_END`/`STT_END`, so the decision has to happen here.\n *\n * This is a plain energy gate, not a neural VAD — good enough to close an utterance, and it costs\n * nothing per frame. Speech must first rise above `startLevel`; after that, `silenceMs` worth of frames\n * below `endLevel` ends it. `maxMs` is the hard stop for rooms that are simply loud.\n *\n * Fixed thresholds only work when the microphone level is known, and on a real satellite it is not.\n * Measured on a ThirdReality Voice & Music Assistant: the device plays a wake-acknowledgement chirp\n * into its own microphone that peaks at full scale (RMS ~16000, clipped), while the speech that follows\n * arrives at RMS 358–469 — below the 700 default for `startLevel` and right on the 400 for `endLevel`.\n * That breaks the gate twice over: `sawSpeech` is satisfied by the chirp rather than by any voice, so\n * the \"nothing but speech-free audio\" guard never fires, and half the real speech frames count as\n * silence, so the utterance can be cut off mid-sentence.\n *\n * Hence two opt-in knobs, both enabled by the ESPHome transport:\n *\n * - `skipMs` drops the leading chirp from the *analysis* (never from the audio, which still goes to STT\n * in full — a fast speaker may already be talking).\n * - `adaptive` derives the thresholds from a tracked noise floor instead of fixed levels. The floor\n * follows the quietest frame seen (instant down, very slow up), so it settles on room noise even when\n * someone starts talking immediately — which a mean or median over a calibration window would not.\n */\n\n/**\n * A buffer that may be a view onto any `ArrayBufferLike` — what `Buffer.subarray()` and\n * `Buffer.concat()` hand back. Plain `Buffer` (= `Buffer<ArrayBuffer>`) assigns to it, not the reverse.\n */\nexport type Bytes = Buffer<ArrayBufferLike>;\n\n/** Root-mean-square amplitude (0…32767) of a mono 16-bit signed LE PCM buffer. */\nexport function rms(pcm: Bytes): number {\n const samples = Math.floor(pcm.length / 2);\n if (!samples) {\n return 0;\n }\n let sum = 0;\n for (let i = 0; i < samples; i++) {\n const value = pcm.readInt16LE(i * 2);\n sum += value * value;\n }\n return Math.sqrt(sum / samples);\n}\n\nexport interface SpeechDetectorOptions {\n /** Sample rate of the incoming PCM (ESPHome satellites always capture at 16 kHz). */\n sampleRate?: number;\n /** Analysis window; 30 ms is the usual compromise between latency and stability. */\n frameMs?: number;\n /** RMS above which we consider speech to have started. Ignored when `adaptive` is on. */\n startLevel?: number;\n /** RMS below which a frame counts as silence. Ignored when `adaptive` is on. */\n endLevel?: number;\n /** Silence after speech that ends the utterance. */\n silenceMs?: number;\n /** Hard cap on one utterance, in case the room never falls quiet. */\n maxMs?: number;\n /**\n * Give up when no speech has started this long after the stream began (the skipped lead-in included);\n * 0 = only {@link maxMs}. A false wake would otherwise keep the microphone open for the whole `maxMs`.\n */\n startTimeoutMs?: number;\n /**\n * Leading audio to leave out of the analysis — for devices that chirp into their own microphone\n * when the wake word fires. The audio itself is untouched; this only stops the chirp from being\n * mistaken for speech and from poisoning the noise floor.\n */\n skipMs?: number;\n /** Derive the thresholds from the tracked noise floor instead of using fixed levels. */\n adaptive?: boolean;\n /** Adaptive mode: `startLevel` is at least this, however quiet the room is. */\n minStartLevel?: number;\n /** Adaptive mode: `endLevel` is at least this. */\n minEndLevel?: number;\n /** Adaptive mode: speech threshold as a multiple of the noise floor. */\n startFactor?: number;\n /** Adaptive mode: silence threshold as a multiple of the noise floor. */\n endFactor?: number;\n /** Adaptive mode: settle the noise floor this long before speech may be declared. */\n warmupMs?: number;\n}\n\n/** Why an utterance ended: the speaker stopped, we hit the hard cap, or nobody started speaking at all. */\nexport type SpeechEnd = 'speech-end' | 'timeout' | 'no-speech';\n\n/**\n * How fast the tracked noise floor may follow a *rising* ambient level, per analysed frame (30 ms).\n * Fast enough that a genuinely noisy room is matched within about a second, slow enough that a voice\n * cannot pull the floor up to its own level before it has been recognised as speech.\n */\nconst NOISE_RISE = 0.02;\n\nexport class SpeechDetector {\n private readonly frameBytes: number;\n private readonly frameMs: number;\n private readonly fixedStart: number;\n private readonly fixedEnd: number;\n private readonly silenceMs: number;\n private readonly maxMs: number;\n private readonly startTimeoutMs: number;\n private readonly skipMs: number;\n private readonly adaptive: boolean;\n private readonly minStartLevel: number;\n private readonly minEndLevel: number;\n private readonly startFactor: number;\n private readonly endFactor: number;\n private readonly warmupMs: number;\n\n private pending: Bytes = Buffer.alloc(0);\n private speechSeen = false;\n private silentMs = 0;\n /** Tracked room level in adaptive mode; null until the first analysed frame. */\n private floor: number | null = null;\n /** Audio seen so far *including* the skipped lead-in, in milliseconds. */\n private elapsedMs = 0;\n /** Audio analysed so far, in milliseconds (exposed for logging). */\n totalMs = 0;\n /** Loudest frame seen so far (exposed for logging — the usual clue when a mic is mis-configured). */\n peak = 0;\n /**\n * Loudest frame after the skipped lead-in, i.e. without the wake chirp. Compared with {@link startLevel}\n * it tells whether a dropped utterance was silence or a voice that was simply too quiet for the gate.\n */\n analysedPeak = 0;\n\n constructor({\n sampleRate = 16000,\n frameMs = 30,\n startLevel = 700,\n endLevel = 400,\n silenceMs = 900,\n maxMs = 12000,\n startTimeoutMs = 0,\n skipMs = 0,\n adaptive = false,\n minStartLevel = 250,\n minEndLevel = 150,\n startFactor = 3.5,\n endFactor = 2,\n warmupMs = 150,\n }: SpeechDetectorOptions = {}) {\n this.frameBytes = Math.round((sampleRate * frameMs) / 1000) * 2;\n this.frameMs = frameMs;\n this.fixedStart = startLevel;\n this.fixedEnd = endLevel;\n this.silenceMs = silenceMs;\n this.maxMs = maxMs;\n this.startTimeoutMs = startTimeoutMs;\n this.skipMs = skipMs;\n this.adaptive = adaptive;\n this.minStartLevel = minStartLevel;\n this.minEndLevel = minEndLevel;\n this.startFactor = startFactor;\n this.endFactor = endFactor;\n this.warmupMs = warmupMs;\n }\n\n /** True once any frame crossed {@link startLevel} — an utterance that never did is room noise. */\n get sawSpeech(): boolean {\n return this.speechSeen;\n }\n\n /** Tracked room level (adaptive mode), for logging. 0 before the first analysed frame. */\n get noiseFloor(): number {\n return this.floor ?? 0;\n }\n\n /** Current speech threshold — fixed, or derived from the noise floor in adaptive mode. */\n get startLevel(): number {\n return this.adaptive ? Math.max(this.minStartLevel, (this.floor ?? 0) * this.startFactor) : this.fixedStart;\n }\n\n /** Current silence threshold. Never above {@link startLevel}, so the gate keeps its hysteresis. */\n get endLevel(): number {\n if (!this.adaptive) {\n return this.fixedEnd;\n }\n return Math.min(this.startLevel, Math.max(this.minEndLevel, (this.floor ?? 0) * this.endFactor));\n }\n\n /** Feed one audio chunk (any size). Returns why the utterance ended, or null to keep listening. */\n push(chunk: Bytes): SpeechEnd | null {\n this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;\n let verdict: SpeechEnd | null = null;\n\n while (this.pending.length >= this.frameBytes) {\n const frame = this.pending.subarray(0, this.frameBytes);\n this.pending = this.pending.subarray(this.frameBytes);\n this.elapsedMs += this.frameMs;\n\n const level = rms(frame);\n if (level > this.peak) {\n this.peak = level;\n }\n\n // The device's own wake chirp: loud, clipped, and not speech. Leaving it out of the\n // analysis is what makes `sawSpeech` mean anything, and keeps the noise floor honest.\n if (this.elapsedMs <= this.skipMs) {\n continue;\n }\n this.totalMs += this.frameMs;\n if (level > this.analysedPeak) {\n this.analysedPeak = level;\n }\n\n if (this.adaptive) {\n // Three rules, each there to defeat a specific way of getting this wrong:\n // - start at zero, i.e. maximally sensitive. Seeding from the first frame instead\n // lets someone who talks straight after the wake word install their own voice as\n // the room's noise floor, which shuts the gate for good — the same trap an average\n // or median over a calibration window falls into.\n // - fall instantly, so a room that went quiet is believed at once.\n // - rise slowly, and not at all once speech has been recognised: otherwise a long\n // utterance drags the floor, and with it `endLevel`, up to its own level and cuts\n // itself off mid-sentence. Falling stays on, so the pauses between words still count.\n this.floor =\n this.floor === null\n ? 0\n : level < this.floor\n ? level\n : this.speechSeen\n ? this.floor\n : this.floor + (level - this.floor) * NOISE_RISE;\n if (this.totalMs < this.warmupMs) {\n continue; // let the floor settle before anything may count as speech\n }\n }\n\n if (!this.speechSeen) {\n if (level >= this.startLevel) {\n this.speechSeen = true;\n this.silentMs = 0;\n }\n } else if (level < this.endLevel) {\n this.silentMs += this.frameMs;\n if (this.silentMs >= this.silenceMs) {\n verdict ||= 'speech-end';\n }\n } else {\n this.silentMs = 0;\n }\n\n if (!this.speechSeen && this.startTimeoutMs > 0 && this.elapsedMs >= this.startTimeoutMs) {\n verdict ||= 'no-speech';\n }\n if (this.totalMs >= this.maxMs) {\n verdict ||= 'timeout';\n }\n }\n return verdict;\n }\n}\n"]}
|
|
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"]}
|
package/build/main.js
CHANGED
|
@@ -1361,6 +1361,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1361
1361
|
unit: entity.unit || undefined,
|
|
1362
1362
|
};
|
|
1363
1363
|
await this.setObjectNotExistsAsync(id, { type: 'state', common, native: {} });
|
|
1364
|
+
if (entity.kind === 'number') {
|
|
1365
|
+
// Objects from before float32() kept the raw float noise (step 0.0010000000474974513);
|
|
1366
|
+
// the device may also change its range with a firmware update.
|
|
1367
|
+
await this.syncNumberRange(id, entity);
|
|
1368
|
+
}
|
|
1364
1369
|
this.subscribeStates(id);
|
|
1365
1370
|
}
|
|
1366
1371
|
}
|
|
@@ -1399,6 +1404,18 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1399
1404
|
await ready;
|
|
1400
1405
|
await this.writeSatelliteControl(satId, entity);
|
|
1401
1406
|
}
|
|
1407
|
+
/** Bring min/max/step of an existing number control in line with what the device announces now. */
|
|
1408
|
+
async syncNumberRange(id, entity) {
|
|
1409
|
+
const obj = await this.getObjectAsync(id);
|
|
1410
|
+
if (!obj || obj.type !== 'state') {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const wanted = { min: entity.min, max: entity.max, step: entity.step };
|
|
1414
|
+
const common = obj.common;
|
|
1415
|
+
if (common.min !== wanted.min || common.max !== wanted.max || common.step !== wanted.step) {
|
|
1416
|
+
await this.extendObjectAsync(id, { common: wanted });
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1402
1419
|
/** Write one entity's current value into its state(s). Always acked — this is the device talking. */
|
|
1403
1420
|
async writeSatelliteControl(satId, entity) {
|
|
1404
1421
|
const base = `satellites.${satId}.controls.${entity.objectId}`;
|
|
@@ -1416,7 +1433,15 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1416
1433
|
await this.setStateAsync(`${base}.progress`, { val: v.progress, ack: true });
|
|
1417
1434
|
}
|
|
1418
1435
|
else {
|
|
1419
|
-
|
|
1436
|
+
let val = entity.value;
|
|
1437
|
+
// The device says how many decimals of a measurement mean something; the rest is noise.
|
|
1438
|
+
if (entity.kind === 'sensor' &&
|
|
1439
|
+
typeof val === 'number' &&
|
|
1440
|
+
entity.decimals !== undefined &&
|
|
1441
|
+
entity.decimals >= 0) {
|
|
1442
|
+
val = Number(val.toFixed(Math.min(entity.decimals, 10)));
|
|
1443
|
+
}
|
|
1444
|
+
await this.setStateAsync(base, { val, ack: true });
|
|
1420
1445
|
}
|
|
1421
1446
|
}
|
|
1422
1447
|
/**
|