@iobroker/assistant-satellite 0.1.3 → 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 CHANGED
@@ -143,6 +143,9 @@ 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.4 (2026-07-05)
147
+ * (@GermanBluefox) Added local listener option
148
+
146
149
  ### 0.1.3 (2026-07-05)
147
150
  * (@GermanBluefox) Added Wake-word probe
148
151
 
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 { loadConfig, DEFAULT_CONFIG, type SatelliteConfig } from './config';
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAqBA,yCAAwC;AAA/B,sGAAA,SAAS,OAAA;AAClB,mCAA4E;AAAnE,oGAAA,UAAU,OAAA;AAAE,wGAAA,cAAc,OAAA;AACnC,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 { loadConfig, DEFAULT_CONFIG, type SatelliteConfig } from './config';\nexport { probeWakeWord, type WakeProbeResult } from './probe';\nexport type { SatelliteState } from './protocol';\n"]}
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iobroker/assistant-satellite",
3
- "version": "0.1.3",
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": {