@iobroker/assistant-satellite 0.1.2 → 0.1.4
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 +4 -1
- package/build/index.d.ts +2 -1
- package/build/index.js +4 -1
- package/build/index.js.map +1 -1
- package/build/localListener.d.ts +38 -0
- package/build/localListener.js +136 -0
- package/build/localListener.js.map +1 -0
- package/build/probe.js +4 -2
- package/build/probe.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,7 +143,10 @@ threshold should be validated on the target device.
|
|
|
143
143
|
Placeholder for the next version (at the beginning of the line):
|
|
144
144
|
### **WORK IN PROGRESS**
|
|
145
145
|
-->
|
|
146
|
-
### 0.1.
|
|
146
|
+
### 0.1.4 (2026-07-05)
|
|
147
|
+
* (@GermanBluefox) Added local listener option
|
|
148
|
+
|
|
149
|
+
### 0.1.3 (2026-07-05)
|
|
147
150
|
* (@GermanBluefox) Added Wake-word probe
|
|
148
151
|
|
|
149
152
|
### 0.0.4 (2026-07-05)
|
package/build/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface SatelliteHost {
|
|
|
17
17
|
onStatus?(state: SatelliteState): void;
|
|
18
18
|
}
|
|
19
19
|
export { Satellite } from './satellite';
|
|
20
|
-
export {
|
|
20
|
+
export { LocalListener, type LocalListenerHost } from './localListener';
|
|
21
|
+
export { loadConfig, DEFAULT_CONFIG, parseWakewords, type SatelliteConfig } from './config';
|
|
21
22
|
export { probeWakeWord, type WakeProbeResult } from './probe';
|
|
22
23
|
export type { SatelliteState } from './protocol';
|
package/build/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.probeWakeWord = exports.DEFAULT_CONFIG = exports.loadConfig = exports.Satellite = void 0;
|
|
3
|
+
exports.probeWakeWord = exports.parseWakewords = exports.DEFAULT_CONFIG = exports.loadConfig = exports.LocalListener = exports.Satellite = void 0;
|
|
4
4
|
var satellite_1 = require("./satellite");
|
|
5
5
|
Object.defineProperty(exports, "Satellite", { enumerable: true, get: function () { return satellite_1.Satellite; } });
|
|
6
|
+
var localListener_1 = require("./localListener");
|
|
7
|
+
Object.defineProperty(exports, "LocalListener", { enumerable: true, get: function () { return localListener_1.LocalListener; } });
|
|
6
8
|
var config_1 = require("./config");
|
|
7
9
|
Object.defineProperty(exports, "loadConfig", { enumerable: true, get: function () { return config_1.loadConfig; } });
|
|
8
10
|
Object.defineProperty(exports, "DEFAULT_CONFIG", { enumerable: true, get: function () { return config_1.DEFAULT_CONFIG; } });
|
|
11
|
+
Object.defineProperty(exports, "parseWakewords", { enumerable: true, get: function () { return config_1.parseWakewords; } });
|
|
9
12
|
var probe_1 = require("./probe");
|
|
10
13
|
Object.defineProperty(exports, "probeWakeWord", { enumerable: true, get: function () { return probe_1.probeWakeWord; } });
|
|
11
14
|
//# sourceMappingURL=index.js.map
|
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAqBA,yCAAwC;AAA/B,sGAAA,SAAS,OAAA;AAClB,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAqBA,yCAAwC;AAA/B,sGAAA,SAAS,OAAA;AAClB,iDAAwE;AAA/D,8GAAA,aAAa,OAAA;AACtB,mCAA4F;AAAnF,oGAAA,UAAU,OAAA;AAAE,wGAAA,cAAc,OAAA;AAAE,wGAAA,cAAc,OAAA;AACnD,iCAA8D;AAArD,sGAAA,aAAa,OAAA","sourcesContent":["/**\n * \\@iobroker/assistant-satellite — public API.\n *\n * The Satellite is dependency-injected with a host (logger + status callback) so the same code runs\n * standalone (CLI) and, later, inside an ioBroker adapter wrapper — with no ioBroker dependency here.\n */\nimport type { SatelliteState } from './protocol';\n\nexport interface Logger {\n info(m: string): void;\n warn(m: string): void;\n error(m: string): void;\n debug(m: string): void;\n}\n\nexport interface SatelliteHost {\n log: Logger;\n /** Called on every state transition (idle/listening/processing/speaking). */\n onStatus?(state: SatelliteState): void;\n}\n\nexport { Satellite } from './satellite';\nexport { LocalListener, type LocalListenerHost } from './localListener';\nexport { loadConfig, DEFAULT_CONFIG, parseWakewords, type SatelliteConfig } from './config';\nexport { probeWakeWord, type WakeProbeResult } from './probe';\nexport type { SatelliteState } from './protocol';\n"]}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type SatelliteConfig } from './config';
|
|
2
|
+
import type { Logger, SatelliteState } from './index';
|
|
3
|
+
export interface LocalListenerHost {
|
|
4
|
+
log: Logger;
|
|
5
|
+
onStatus?(state: SatelliteState): void;
|
|
6
|
+
/**
|
|
7
|
+
* A complete recorded utterance (16 kHz mono 16-bit PCM). Return the reply audio to play back, or
|
|
8
|
+
* null to stay silent (e.g. nothing recognised).
|
|
9
|
+
*/
|
|
10
|
+
onUtterance(pcm: Buffer, sampleRate: number): Promise<{
|
|
11
|
+
pcm: Buffer;
|
|
12
|
+
sampleRate: number;
|
|
13
|
+
} | null>;
|
|
14
|
+
}
|
|
15
|
+
export declare class LocalListener {
|
|
16
|
+
private readonly cfg;
|
|
17
|
+
private readonly host;
|
|
18
|
+
private wakeword;
|
|
19
|
+
private mic;
|
|
20
|
+
private readonly backend;
|
|
21
|
+
private plingPcm;
|
|
22
|
+
private micRemainder;
|
|
23
|
+
private preBuffer;
|
|
24
|
+
private recording;
|
|
25
|
+
private recChunks;
|
|
26
|
+
private silence;
|
|
27
|
+
private pumping;
|
|
28
|
+
private running;
|
|
29
|
+
private playbackProc;
|
|
30
|
+
constructor(cfg: SatelliteConfig, host: LocalListenerHost);
|
|
31
|
+
start(): Promise<void>;
|
|
32
|
+
stop(): Promise<void>;
|
|
33
|
+
private setStatus;
|
|
34
|
+
private pump;
|
|
35
|
+
private handleFrame;
|
|
36
|
+
private startRecording;
|
|
37
|
+
private endRecording;
|
|
38
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocalListener = void 0;
|
|
4
|
+
const wakeword_1 = require("./wakeword");
|
|
5
|
+
const models_1 = require("./models");
|
|
6
|
+
const audio_1 = require("./audio");
|
|
7
|
+
const vad_1 = require("./vad");
|
|
8
|
+
const protocol_1 = require("./protocol");
|
|
9
|
+
const config_1 = require("./config");
|
|
10
|
+
const FRAME_BYTES = 1280 * 2; // 80 ms @ 16 kHz mono 16-bit
|
|
11
|
+
const FRAME_MS = 80;
|
|
12
|
+
class LocalListener {
|
|
13
|
+
cfg;
|
|
14
|
+
host;
|
|
15
|
+
wakeword;
|
|
16
|
+
mic = null;
|
|
17
|
+
backend;
|
|
18
|
+
plingPcm = Buffer.alloc(0);
|
|
19
|
+
micRemainder = Buffer.alloc(0);
|
|
20
|
+
preBuffer = [];
|
|
21
|
+
recording = false;
|
|
22
|
+
recChunks = [];
|
|
23
|
+
silence = null;
|
|
24
|
+
pumping = false;
|
|
25
|
+
running = false;
|
|
26
|
+
playbackProc = null;
|
|
27
|
+
constructor(cfg, host) {
|
|
28
|
+
this.cfg = cfg;
|
|
29
|
+
this.host = host;
|
|
30
|
+
this.backend = (0, audio_1.resolveBackend)(cfg.audioBackend);
|
|
31
|
+
}
|
|
32
|
+
async start() {
|
|
33
|
+
const words = (0, config_1.parseWakewords)(this.cfg.wakewordModel);
|
|
34
|
+
const modelSets = await Promise.all(words.map(w => (0, models_1.ensureModels)(this.cfg.modelsDir, w, this.host.log)));
|
|
35
|
+
this.wakeword = new wakeword_1.WakeWord(modelSets, this.cfg.wakewordThreshold, this.host.log);
|
|
36
|
+
await this.wakeword.load();
|
|
37
|
+
this.plingPcm = (0, audio_1.pling)();
|
|
38
|
+
this.running = true;
|
|
39
|
+
this.mic = new audio_1.Mic(this.backend, this.cfg.micDevice, this.host.log);
|
|
40
|
+
this.mic.start(d => {
|
|
41
|
+
this.micRemainder = Buffer.concat([this.micRemainder, d]);
|
|
42
|
+
if (!this.pumping) {
|
|
43
|
+
void this.pump();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
this.setStatus('idle');
|
|
47
|
+
this.host.log.info('Local listener ready — listening for the wake word (ioBroker transport, no UDP).');
|
|
48
|
+
}
|
|
49
|
+
async stop() {
|
|
50
|
+
this.running = false;
|
|
51
|
+
this.playbackProc?.kill();
|
|
52
|
+
this.playbackProc = null;
|
|
53
|
+
await this.mic?.stop();
|
|
54
|
+
this.mic = null;
|
|
55
|
+
}
|
|
56
|
+
setStatus(state) {
|
|
57
|
+
this.host.onStatus?.(state);
|
|
58
|
+
}
|
|
59
|
+
async pump() {
|
|
60
|
+
this.pumping = true;
|
|
61
|
+
try {
|
|
62
|
+
while (this.micRemainder.length >= FRAME_BYTES) {
|
|
63
|
+
const frame = Buffer.from(this.micRemainder.subarray(0, FRAME_BYTES));
|
|
64
|
+
this.micRemainder = this.micRemainder.subarray(FRAME_BYTES);
|
|
65
|
+
await this.handleFrame(frame);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
this.host.log.error(`frame processing error: ${e.message}`);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
this.pumping = false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async handleFrame(frame) {
|
|
76
|
+
if (this.recording) {
|
|
77
|
+
this.recChunks.push(frame);
|
|
78
|
+
if (this.silence.push(frame)) {
|
|
79
|
+
await this.endRecording();
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Wake-word listening with a rolling pre-buffer so speech during inference is not lost.
|
|
84
|
+
this.preBuffer.push(frame);
|
|
85
|
+
if (this.preBuffer.length > this.cfg.preBufferChunks) {
|
|
86
|
+
this.preBuffer.shift();
|
|
87
|
+
}
|
|
88
|
+
const score = await this.wakeword.process(frame);
|
|
89
|
+
if (this.wakeword.triggered(score)) {
|
|
90
|
+
this.host.log.info(`Wake word detected (score ${score.toFixed(3)}).`);
|
|
91
|
+
this.wakeword.reset();
|
|
92
|
+
await this.startRecording();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async startRecording() {
|
|
96
|
+
this.setStatus('listening');
|
|
97
|
+
await (0, audio_1.playPcm)(this.plingPcm, protocol_1.AUDIO_SAMPLE_RATE, this.backend, this.cfg.speakerDevice, this.host.log).done;
|
|
98
|
+
// Drop the beep echo + inference backlog so recording runs in real time (else the silence
|
|
99
|
+
// detector races through buffered quiet frames and ends the utterance too early).
|
|
100
|
+
this.micRemainder = Buffer.alloc(0);
|
|
101
|
+
this.preBuffer = [];
|
|
102
|
+
this.recChunks = [];
|
|
103
|
+
this.silence = new vad_1.SilenceDetector(this.cfg.silenceThreshold, Math.round(this.cfg.silenceMs / FRAME_MS), Math.round(this.cfg.minRecordMs / FRAME_MS), Math.round(this.cfg.maxRecordMs / FRAME_MS));
|
|
104
|
+
this.recording = true;
|
|
105
|
+
}
|
|
106
|
+
async endRecording() {
|
|
107
|
+
this.recording = false;
|
|
108
|
+
const pcm = Buffer.concat(this.recChunks);
|
|
109
|
+
this.recChunks = [];
|
|
110
|
+
this.setStatus('processing');
|
|
111
|
+
try {
|
|
112
|
+
const reply = await this.host.onUtterance(pcm, protocol_1.AUDIO_SAMPLE_RATE);
|
|
113
|
+
if (reply && reply.pcm.length && this.running) {
|
|
114
|
+
this.setStatus('speaking');
|
|
115
|
+
const { proc, done } = (0, audio_1.playPcm)(reply.pcm, reply.sampleRate, this.backend, this.cfg.speakerDevice, this.host.log);
|
|
116
|
+
this.playbackProc = proc;
|
|
117
|
+
try {
|
|
118
|
+
await done;
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
this.playbackProc = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
this.host.log.error(`utterance processing failed: ${e.message}`);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
this.micRemainder = Buffer.alloc(0); // discard audio captured while processing/speaking
|
|
130
|
+
this.preBuffer = [];
|
|
131
|
+
this.setStatus('idle');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
exports.LocalListener = LocalListener;
|
|
136
|
+
//# sourceMappingURL=localListener.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"localListener.js","sourceRoot":"","sources":["../src/localListener.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Local wake-word + record loop with NO network transport. Captures the microphone, detects the wake\n * word, records one utterance (until silence), hands the full PCM to `host.onUtterance`, and plays the\n * reply audio it returns. The ioBroker adapter uses this to exchange audio with the assistant over the\n * message bus (sendTo) instead of the UDP voice protocol — so no UDP port and STT/TTS stay central.\n *\n * Standalone (ESP / no ioBroker) satellites keep using the UDP `Satellite` class; this is the\n * ioBroker-native alternative.\n */\nimport type { ChildProcess } from 'node:child_process';\nimport { WakeWord } from './wakeword';\nimport { ensureModels } from './models';\nimport { Mic, playPcm, pling, resolveBackend, type AudioBackend } from './audio';\nimport { SilenceDetector } from './vad';\nimport { AUDIO_SAMPLE_RATE } from './protocol';\nimport { parseWakewords, type SatelliteConfig } from './config';\nimport type { Logger, SatelliteState } from './index';\n\nconst FRAME_BYTES = 1280 * 2; // 80 ms @ 16 kHz mono 16-bit\nconst FRAME_MS = 80;\n\nexport interface LocalListenerHost {\n log: Logger;\n onStatus?(state: SatelliteState): void;\n /**\n * A complete recorded utterance (16 kHz mono 16-bit PCM). Return the reply audio to play back, or\n * null to stay silent (e.g. nothing recognised).\n */\n onUtterance(pcm: Buffer, sampleRate: number): Promise<{ pcm: Buffer; sampleRate: number } | null>;\n}\n\nexport class LocalListener {\n private wakeword!: WakeWord;\n private mic: Mic | null = null;\n private readonly backend: AudioBackend;\n private plingPcm: Buffer = Buffer.alloc(0);\n\n private micRemainder: Buffer = Buffer.alloc(0);\n private preBuffer: Buffer[] = [];\n private recording = false;\n private recChunks: Buffer[] = [];\n private silence: SilenceDetector | null = null;\n private pumping = false;\n private running = false;\n private playbackProc: ChildProcess | null = null;\n\n constructor(\n private readonly cfg: SatelliteConfig,\n private readonly host: LocalListenerHost,\n ) {\n this.backend = resolveBackend(cfg.audioBackend);\n }\n\n async start(): Promise<void> {\n const words = parseWakewords(this.cfg.wakewordModel);\n const modelSets = await Promise.all(words.map(w => ensureModels(this.cfg.modelsDir, w, this.host.log)));\n this.wakeword = new WakeWord(modelSets, this.cfg.wakewordThreshold, this.host.log);\n await this.wakeword.load();\n this.plingPcm = pling();\n this.running = true;\n this.mic = new Mic(this.backend, this.cfg.micDevice, this.host.log);\n this.mic.start(d => {\n this.micRemainder = Buffer.concat([this.micRemainder, d]);\n if (!this.pumping) {\n void this.pump();\n }\n });\n this.setStatus('idle');\n this.host.log.info('Local listener ready — listening for the wake word (ioBroker transport, no UDP).');\n }\n\n async stop(): Promise<void> {\n this.running = false;\n this.playbackProc?.kill();\n this.playbackProc = null;\n await this.mic?.stop();\n this.mic = null;\n }\n\n private setStatus(state: SatelliteState): void {\n this.host.onStatus?.(state);\n }\n\n private async pump(): Promise<void> {\n this.pumping = true;\n try {\n while (this.micRemainder.length >= FRAME_BYTES) {\n const frame = Buffer.from(this.micRemainder.subarray(0, FRAME_BYTES));\n this.micRemainder = this.micRemainder.subarray(FRAME_BYTES);\n await this.handleFrame(frame);\n }\n } catch (e) {\n this.host.log.error(`frame processing error: ${(e as Error).message}`);\n } finally {\n this.pumping = false;\n }\n }\n\n private async handleFrame(frame: Buffer): Promise<void> {\n if (this.recording) {\n this.recChunks.push(frame);\n if (this.silence!.push(frame)) {\n await this.endRecording();\n }\n return;\n }\n // Wake-word listening with a rolling pre-buffer so speech during inference is not lost.\n this.preBuffer.push(frame);\n if (this.preBuffer.length > this.cfg.preBufferChunks) {\n this.preBuffer.shift();\n }\n const score = await this.wakeword.process(frame);\n if (this.wakeword.triggered(score)) {\n this.host.log.info(`Wake word detected (score ${(score as number).toFixed(3)}).`);\n this.wakeword.reset();\n await this.startRecording();\n }\n }\n\n private async startRecording(): Promise<void> {\n this.setStatus('listening');\n await playPcm(this.plingPcm, AUDIO_SAMPLE_RATE, this.backend, this.cfg.speakerDevice, this.host.log).done;\n // Drop the beep echo + inference backlog so recording runs in real time (else the silence\n // detector races through buffered quiet frames and ends the utterance too early).\n this.micRemainder = Buffer.alloc(0);\n this.preBuffer = [];\n this.recChunks = [];\n this.silence = new SilenceDetector(\n this.cfg.silenceThreshold,\n Math.round(this.cfg.silenceMs / FRAME_MS),\n Math.round(this.cfg.minRecordMs / FRAME_MS),\n Math.round(this.cfg.maxRecordMs / FRAME_MS),\n );\n this.recording = true;\n }\n\n private async endRecording(): Promise<void> {\n this.recording = false;\n const pcm = Buffer.concat(this.recChunks);\n this.recChunks = [];\n this.setStatus('processing');\n try {\n const reply = await this.host.onUtterance(pcm, AUDIO_SAMPLE_RATE);\n if (reply && reply.pcm.length && this.running) {\n this.setStatus('speaking');\n const { proc, done } = playPcm(\n reply.pcm,\n reply.sampleRate,\n this.backend,\n this.cfg.speakerDevice,\n this.host.log,\n );\n this.playbackProc = proc;\n try {\n await done;\n } finally {\n this.playbackProc = null;\n }\n }\n } catch (e) {\n this.host.log.error(`utterance processing failed: ${(e as Error).message}`);\n } finally {\n this.micRemainder = Buffer.alloc(0); // discard audio captured while processing/speaking\n this.preBuffer = [];\n this.setStatus('idle');\n }\n }\n}\n"]}
|
package/build/probe.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.probeWakeWord = probeWakeWord;
|
|
|
7
7
|
* verify detection from the GUI — works even before the satellite is fully configured (no server needed).
|
|
8
8
|
*/
|
|
9
9
|
const models_1 = require("./models");
|
|
10
|
+
const config_1 = require("./config");
|
|
10
11
|
const wakeword_1 = require("./wakeword");
|
|
11
12
|
const audio_1 = require("./audio");
|
|
12
13
|
const vad_1 = require("./vad");
|
|
@@ -18,8 +19,9 @@ const FRAME_BYTES = 1280 * 2; // 80 ms @ 16 kHz mono 16-bit
|
|
|
18
19
|
async function probeWakeWord(cfg, log, seconds,
|
|
19
20
|
/** Called per frame with the CURRENT score/RMS and whether the wake word has fired so far. */
|
|
20
21
|
onProgress) {
|
|
21
|
-
const
|
|
22
|
-
const
|
|
22
|
+
const words = (0, config_1.parseWakewords)(cfg.wakewordModel);
|
|
23
|
+
const modelSets = await Promise.all(words.map(w => (0, models_1.ensureModels)(cfg.modelsDir, w, log)));
|
|
24
|
+
const wakeword = new wakeword_1.WakeWord(modelSets, cfg.wakewordThreshold, log);
|
|
23
25
|
await wakeword.load();
|
|
24
26
|
const mic = new audio_1.Mic((0, audio_1.resolveBackend)(cfg.audioBackend), cfg.micDevice, log);
|
|
25
27
|
let remainder = Buffer.alloc(0);
|
package/build/probe.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"probe.js","sourceRoot":"","sources":["../src/probe.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"probe.js","sourceRoot":"","sources":["../src/probe.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Wake-word probe: open the microphone for a few seconds and report whether the wake word was\n * detected and the peak score/level seen. Used by the adapter's \"Test wake word\" button so users can\n * verify detection from the GUI — works even before the satellite is fully configured (no server needed).\n */\nimport { ensureModels } from './models';\nimport { parseWakewords } from './config';\nimport { WakeWord } from './wakeword';\nimport { Mic, resolveBackend } from './audio';\nimport { rms } from './vad';\nimport type { SatelliteConfig } from './config';\nimport type { Logger } from './index';\n\nconst FRAME_BYTES = 1280 * 2; // 80 ms @ 16 kHz mono 16-bit\n\nexport interface WakeProbeResult {\n detected: boolean;\n peakScore: number;\n peakRms: number;\n frames: number;\n threshold: number;\n}\n\n/**\n * Listen for `seconds` and run wake-word detection. `onProgress` is called with the running peak\n * score/RMS so the caller can show a live indicator. Resolves when the window ends.\n */\nexport async function probeWakeWord(\n cfg: SatelliteConfig,\n log: Logger,\n seconds: number,\n /** Called per frame with the CURRENT score/RMS and whether the wake word has fired so far. */\n onProgress?: (score: number, rms: number, detected: boolean) => void,\n): Promise<WakeProbeResult> {\n const words = parseWakewords(cfg.wakewordModel);\n const modelSets = await Promise.all(words.map(w => ensureModels(cfg.modelsDir, w, log)));\n const wakeword = new WakeWord(modelSets, cfg.wakewordThreshold, log);\n await wakeword.load();\n const mic = new Mic(resolveBackend(cfg.audioBackend), cfg.micDevice, log);\n\n let remainder = Buffer.alloc(0);\n let pumping = false;\n let peakScore = 0;\n let peakRms = 0;\n let frames = 0;\n let detected = false;\n\n const pump = async (): Promise<void> => {\n if (pumping) {\n return;\n }\n pumping = true;\n try {\n while (remainder.length >= FRAME_BYTES) {\n const frame = Buffer.from(remainder.subarray(0, FRAME_BYTES));\n remainder = remainder.subarray(FRAME_BYTES);\n frames++;\n const level = rms(frame);\n if (level > peakRms) {\n peakRms = level;\n }\n const score = await wakeword.process(frame);\n const current = score ?? 0;\n if (current > peakScore) {\n peakScore = current;\n }\n if (wakeword.triggered(score)) {\n detected = true;\n }\n onProgress?.(current, level, detected); // current frame values for a live meter\n }\n } finally {\n pumping = false;\n }\n };\n\n mic.start(d => {\n remainder = Buffer.concat([remainder, d]);\n void pump();\n });\n await new Promise<void>(res => setTimeout(res, Math.max(1, seconds) * 1000));\n await new Promise<void>(res => setTimeout(res, 200)); // let the last frames drain\n await mic.stop();\n\n return { detected, peakScore, peakRms, frames, threshold: cfg.wakewordThreshold };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iobroker/assistant-satellite",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Standalone voice satellite for the ioBroker.assistant adapter (wake word + mic streaming + TTS playback over the Hannah UDP protocol). Runs on a bare Pi via npx, no ioBroker required.",
|
|
5
5
|
"author": "ioBroker",
|
|
6
6
|
"publishConfig": {
|