iobroker.assistant 1.4.1 → 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 +6 -0
- 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/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/io-package.json +14 -14
- package/package.json +1 -1
|
@@ -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":"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","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/io-package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "assistant",
|
|
4
|
-
"version": "1.4.
|
|
4
|
+
"version": "1.4.2",
|
|
5
5
|
"news": {
|
|
6
|
+
"1.4.2": {
|
|
7
|
+
"en": "ESPHome satellites: after 2.5 seconds of speech a pause may be half as long again before the request ends, so a pause for thought no longer cuts off a longer request. Short commands close as fast as before\nESPHome satellites: speech recognition gets the recording without the device's chirp or beep and without the silence around the speech, and quiet speech is amplified (up to ×10). Whisper made \"Продолжение следует...\" of the beep and the silence\nThe speech-to-text fallback also steps in when the first engine returns no text for speech\nRequest details: the speech recognition step shows the level profile of the recording (RMS per 100 ms), where speech started, why the recording ended and what went to the recognition",
|
|
8
|
+
"de": "ESPHome-Satelliten: Nach 2,5 Sekunden Rede kann eine Pause auch halb so lang sein, bis die Anfrage endet. Kurzbefehle schließen so schnell wie zuvor\nESPHome-Satelliten: Spracherkennung nimmt die Aufnahme ohne Zwitschern oder Piepton des Geräts auf, ohne Stille und leise Sprache wird verstärkt (bis zu ×10). Whisper machte aus dem Piepton und der Stille „\nDas Sprache-zu-Text-Fallback springt auch ein, wenn die erste Engine keinen Text liefert\nAnforderungsdetails: Die Spracherkennung zeigt das Pegelprofil der Aufnahme (RMS pro 100 ms), wo die Sprache begann, warum sie endete und was zur Erkennung ging",
|
|
9
|
+
"ru": "Спутники ESPhome: после 2,5 секунд речи пауза снова может быть вдвое меньше, прежде чем запрос закончится, поэтому пауза в размышлениях больше не прерывает более продолжительный запрос. Короткие команды закрываются так же быстро, как и раньше\nСпутники eSPhome: распознавание речи обеспечивает запись без чириканья или звукового сигнала устройства и без тишины вокруг речи, а тихая речь усиливается (до ×10). Из звукового сигнала и тишины раздался шепот: «Прощай...»\nРезервный вариант преобразования речи в текст также срабатывает, когда первый движок не возвращает текст для речи.\nСведения о запросе: на этапе распознавания речи отображается профиль уровня записи (RMS за 100 мс), где началась речь, почему запись закончилась и что пошло на распознавание",
|
|
10
|
+
"pt": "Satélites ESPHome: após 2,5 segundos de fala, uma pausa pode durar metade do tempo do pedido, então uma pausa para pensar não interrompe um pedido mais longo. Comandos curtos fecham mais rápido\nSatélites ESPHome: o reconhecimento de voz grava sem o som do dispositivo e sem o silêncio ao redor da fala, e a fala baixa é amplificada (até × 10). Um sussurro feito “...” do bipe e do silêncio\nA conversão de voz em texto também ocorre quando o primeiro motor não retorna texto.\nDetalhes do pedido: a etapa de reconhecimento de fala mostra o perfil de nível da gravação (RMS por 100 ms), onde a fala começou, por que terminou e o que foi para o reconhecimento",
|
|
11
|
+
"nl": "ESPhome-satellieten: na 2,5 seconde spreken kan een pauze weer half zo lang duren voordat het verzoek is afgelopen, zodat een pauze om na te denken niet langer een langer verzoek afsluit. Korte opdrachten sluiten net zo snel als voorheen\nESPhome-satellieten: met spraakherkenning wordt de opname gemaakt zonder dat het apparaat piept of piept en zonder de stilte rondom de spraak, en rustige spraak wordt versterkt (tot ×10). Fluister „...” van de pieptoon en de stilte\nDe terugval van spraak naar tekst treedt ook op wanneer de eerste engine geen tekst retourneert voor spraak\nVerzoekgegevens: de stap voor spraakherkenning toont het niveauprofiel van de opname (RMS per 100 ms), waar de spraak begon, waarom de opname eindigde en wat er naar de herkenning is gegaan",
|
|
12
|
+
"fr": "Satellites ESPhome : après 2,5 secondes de parole, une pause peut être réduite de moitié avant la fin de la demande. Ainsi, une pause de réflexion n'interrompt plus une demande. Les commandes courtes ferment aussi vite\nSatellites ESPhome : la reconnaissance vocale enregistre sans bruit ni bip de l'appareil et sans le silence qui entoure le discours, et le silence est amplifié (jusqu'à 10). Whisper a émis « продолениследует... » à partir du bip et du silence\nLa solution parole-texte intervient également lorsque le premier moteur ne renvoie aucun texte\nDétails de la demande : l'étape de reconnaissance vocale indique le profil de niveau de l'enregistrement (RMS pour 100 ms), le début, la fin de l'enregistrement et le résultat de la reconnaissance",
|
|
13
|
+
"it": "Satelliti ESPhome: dopo 2,5 secondi di discorso una pausa può durare la metà della richiesta, quindi una pausa di riflessione non interrompe più una richiesta. I comandi brevi si chiudono come prima\nSatelliti ESPhome: il riconoscimento vocale registra senza cinguettio o bip del dispositivo e senza silenzio, e il parlato silenzioso è amplificato (fino a ×10). Whisper emise «Продолнение следует...» con il bip e il silenzio\nLa riserva vocale interviene anche quando il primo motore non restituisce alcun testo\nRichiesta dettagli: la fase di riconoscimento vocale mostra il profilo di livello della registrazione (RMS per 100 ms), dove è iniziata la voce, perché è finita",
|
|
14
|
+
"es": "Satélites ESPhome: tras 2,5 segundos de voz, una pausa puede ser la mitad antes de que termine la solicitud, por lo que una pausa para pensar ya no interrumpe una solicitud más larga. Los comandos cortos cierran tan rápido como antes\nSatélites ESPhome: el reconocimiento de voz graba sin el chirrido o el pitido del dispositivo y sin el silencio que rodea al discurso, y la voz baja se amplifica (hasta × 10). Whisper hizo «родолененееееееееес...» del pitido y el silencio\nLa alternativa de voz a texto también interviene cuando el primer motor no devuelve texto por voz\nDetalles de la solicitud: el paso de reconocimiento de voz muestra el perfil de niveles de la grabación (RMS cada 100 ms), dónde comenzó la voz, por qué terminó la grabación y qué pasó al reconocimiento",
|
|
15
|
+
"pl": "Satelity ESPhome: po 2,5 sekundy mowy przerwa może być ponownie o połowę dłuższa przed zakończeniem żądania, więc przerwa na myślenie nie odcina już dłuższego żądania. Krótkie polecenia zamykają się tak szybko jak poprzednio\nSatelity ESPhome: rozpoznawanie mowy umożliwia nagrywanie bez ćwierkania lub sygnału dźwiękowego urządzenia i bez ciszy wokół mowy, a cicha mowa jest wzmacniana (do × 10). Szeptem dźwięku dźwiękowego i ciszy „Служба следует...”\nFunkcja zwrotna mowy na tekst pojawia się również wtedy, gdy pierwszy silnik nie zwraca tekstu do mowy\nSzczegóły żądania: etap rozpoznawania mowy pokazuje profil poziomu nagrania (RMS na 100 ms), gdzie rozpoczęła się mowa, dlaczego nagranie się zakończyło i co przeszło do rozpoznawania",
|
|
16
|
+
"uk": "Супутники ESPHome: після 2,5 секунди мови пауза може бути вдвічі довше, перш ніж запит закінчиться, тому пауза для роздумів більше не припиняє більш тривалий запит. Короткі команди закриваються так само швидко, як і раніше\nСупутники ESPHome: розпізнавання мовлення отримує запис без щебетання або звукового сигналу пристрою та без тиші навколо мови, а тиха мова посилюється (до × 10). Шепіт зробив «Продолжение следует...» звукового сигналу і тиші\nЗапасний режим мовлення в текст також вступає, коли перший механізм не повертає текст для мови\nДеталі запиту: крок розпізнавання мови показує профіль рівня запису (RMS на 100 мс), звідки почалася мова, чому закінчився запис і що пішло на розпізнавання",
|
|
17
|
+
"zh-cn": "espHome 卫星:在 2.5 秒的语音之后,在请求结束之前,暂停的时间可能会再延长一半,因此暂停思考不会再中断更长的请求。短命令的关闭速度和以前一样快\nespHome 卫星:语音识别可在不发出设备鸣叫声或蜂鸣声的情况下进行录音,语音周围也不会保持沉默,静音语音会被放大(最大 ×10)。Whisper 制作了 “哔哔声和沉默...”\n当第一个引擎未返回任何语音文本时,语音转文本的备用机制也会介入\n请求详情:语音识别步骤显示录音的电平分布(每 100 毫秒 RMS)、语音开始地点、录制结束的原因以及识别的内容"
|
|
18
|
+
},
|
|
6
19
|
"1.4.1": {
|
|
7
20
|
"en": "Satellite settings: compound controls show their name (\"Firmware Update\") instead of the object id (\"thirdreality_firmware_update\"), and values of ESPHome devices no longer carry float noise (0.5 instead of 0.5000000237487257, step 0.001 instead of 0.0010000000474974513). Existing number controls get the cleaned range on the next connect\nSatellite settings, firmware update: the install button only appears when another version is available, and a running update shows its progress as a bar",
|
|
8
21
|
"de": "Satelliteneinstellungen: Verbundsteuerungen zeigen ihren Namen („Firmware Update“) statt der Objekt-ID („thirdreality_firmware_update“) und ESPhome-Geräte haben kein Float-Noise mehr (0,5 statt 0,5000000237487257, Schritt 0,001 statt 0,0010000000474974513). Bestehende Rufnummern werden beim nächsten Connect gelöscht\nSatelliteneinstellungen, Firmware-Update: Der Install-Button erscheint nur, wenn eine andere Version verfügbar ist.",
|
|
@@ -80,19 +93,6 @@
|
|
|
80
93
|
"pl": "YouTube w telewizorze: „play the channel XY” znajduje kanał, wideo lub listę odtwarzania na YouTube (nie jest potrzebny klucz API), włącza telewizor, jeśli jest wyłączony, i odtwarza go za pomocą pilota YouTube. Gracze są ustawieni w stole w ustawieniach; pierwszy jest używany, gdy żaden gracz nie jest nazwany. Działają również pauza, pomijanie, głośność i „co gra”",
|
|
81
94
|
"uk": "YouTube на телевізорі: «відтворити канал XY» знаходить канал, відео або список відтворення на YouTube (ключ API не потрібен), вмикає телевізор, якщо він вимкнений, і відтворює його через адаптер youtube-дистанційного керування. Гравці встановлюються в таблиці в налаштуваннях; перший використовується, коли жодного гравця не названо. Пауза, пропуск, гучність і «що грає» теж працюють",
|
|
82
95
|
"zh-cn": "电视上的YouTube:“播放 XY 频道” 可在YouTube上找到该频道、视频或播放列表(无需API密钥),如果电视已关闭,则将其打开,然后通过YouTube远程适配器播放。玩家在设置中的表格中设置;当没有指定玩家时,使用第一个玩家。暂停、跳过、音量和 “正在播放的内容” 也起作用"
|
|
83
|
-
},
|
|
84
|
-
"1.0.3": {
|
|
85
|
-
"en": "The Chat tab now shows every request of the last hours live - from satellites, Telegram, scripts and the chat itself - with the satellite, the detected wake word, which stage answered (rule-based NLU, routine, local LLM, cloud LLM), the tool calls and the state ids that were read and written. How many hours are kept is set in the settings (0 = record nothing)",
|
|
86
|
-
"de": "Der Chat-Tab zeigt jetzt alle Anfragen der letzten Stunden live — von Satelliten, Telegram, Skripten und dem Chat selbst — mit Satellit, erkanntem Weckwort, Antwort (regelbasiertes NLU, Routine, lokales LLM, Cloud-LLM), Toolaufrufen und gelesenen und geschriebenen Status-IDs. Wie viele Stunden gelten, ist vorgegeben (0 = nichts aufnehmen)",
|
|
87
|
-
"ru": "На вкладке «Чат» теперь отображаются все запросы за последние часы в прямом эфире (от спутников, Telegram, сценариев и самого чата) со спутником, обнаруженным словом для пробуждения, ответом на этап (NLU, рутинный, локальный LLM, облачный LLM), прочитанными и записанными идентификаторами состояния. Сколько часов вы храните, задается в настройках (0 = ничего не записывать)",
|
|
88
|
-
"pt": "Agora, a aba Chat mostra todas as solicitações das últimas horas ao vivo — de satélites, Telegram, scripts e o próprio chat — com o satélite, o wake word detectado, o estágio respondido (NLU baseado em regras, rotina, LLM local e nuvem), as chamadas da ferramenta e os IDs de estado lidos e gravados. O número de horas guardadas é definido (0 = não grava nada)",
|
|
89
|
-
"nl": "Op het tabblad Chat wordt nu elk verzoek van de afgelopen uren live weergegeven - van satellieten, Telegram, scripts en de chat zelf - met de satelliet, het gedetecteerde wake-word, in welke fase is beantwoord (NLU op basis van regels, routine, lokale LLM, LLM in de cloud), de oproepen van de tool en de staats-ID's die zijn gelezen en geschreven. Hoeveel uren worden bijgehouden, wordt ingesteld in de instellingen (0 = niets opnemen)",
|
|
90
|
-
"fr": "L'onglet Chat affiche désormais en direct toutes les requêtes des dernières heures (satellites, Telegram, scripts et chat lui-même) avec le satellite, le mot de réveil détecté, la réponse (NLU basée sur des règles, routine, LLM local, LLM cloud), les appels d'outils et les identifiants d'état lus et écrits. Le nombre d'heures est réglé dans les paramètres (0 = rien enregistrer)",
|
|
91
|
-
"it": "La scheda Chat ora mostra tutte le richieste delle ultime ore in diretta - da satelliti, Telegram, script e chat stessa - con il satellite, la parola attiva rilevata, la fase con risposta (NLU basato su regole, routine, LLM locale, cloud LM), le chiamate al tool e gli ID di stato letti e scritti. Quante ore vengono conservate è impostato (0 = nulla)",
|
|
92
|
-
"es": "La pestaña Chat muestra ahora todas las solicitudes de las últimas horas en directo (desde satélites, Telegram, guiones y el propio chat) con el satélite, la palabra de activación detectada, la fase respondida (NLU basada en reglas, rutina, LLM local, LLM en la nube), las llamadas de herramientas y los identificadores estatales leídos y escritos. El número de horas se guardan está en los ajustes (0 = no graba nada)",
|
|
93
|
-
"pl": "Zakładka Czat pokazuje teraz każde żądanie ostatnich godzin na żywo - z satelitów, telegramu, skryptów i samego czatu - z satelitą, wykrytym słowem budzenia, który etap odpowiedział (NLU oparty na regułach, rutyna, lokalny LLM, chmura LLM), wywołania narzędzi i identyfikatory stanu, które zostały odczytane i zapisane. Ile godzin jest przechowywanych jest ustawione w ustawieniach (0 = nic nie rejestruj)",
|
|
94
|
-
"uk": "Вкладка Чат тепер показує кожен запит останніх годин у прямому ефірі - від супутників, Telegram, скриптів та самого чату - із супутником, виявленим словом пробудження, на який етап відповіли (NLU на основі правил, рутина, локальний LLM, хмарний LLM), виклики інструменту та ідентифікатори стану, які були прочитані та записані. Скільки годин зберігається, задається в налаштуваннях (0 = нічого не записувати)",
|
|
95
|
-
"zh-cn": "聊天选项卡现在会显示最近几个小时的实时请求,包括卫星、电报、脚本和聊天本身,包括卫星、检测到的唤醒词、哪个阶段已回答(基于规则的NLU、例程、本地LLM、云端LLM)、工具调用以及读取和写入的状态ID。保留多少小时是在设置中设置的(0 = 不记录任何内容)"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"titleLang": {
|