66 lines
2.8 KiB
JavaScript
66 lines
2.8 KiB
JavaScript
import { EventEmitter } from 'node:events';
|
|
import { spawn } from 'node:child_process';
|
|
|
|
/**
|
|
* Wake engines consume PCM frames. A production wake model can be attached
|
|
* through `detect(frame)`, keeping the daemon independent from a Python or
|
|
* native openWakeWord installation. The default detector is intentionally
|
|
* disabled until a local model command is configured; it never pretends that
|
|
* an energy spike is the wake phrase.
|
|
*/
|
|
export class WakeEngine extends EventEmitter {
|
|
constructor({ phrases = ['hey jarvis', 'jarvis', 'okay jarvis'], detect } = {}) {
|
|
super();
|
|
this.phrases = phrases.map((phrase) => String(phrase).trim().toLowerCase()).filter(Boolean);
|
|
this.detect = detect;
|
|
this.active = true;
|
|
}
|
|
|
|
push(frame) {
|
|
if (!this.active || typeof this.detect !== 'function') return false;
|
|
const result = this.detect(frame);
|
|
if (!result) return false;
|
|
const phrase = typeof result === 'string' ? result : result.phrase;
|
|
if (!phrase || !this.phrases.includes(String(phrase).toLowerCase())) return false;
|
|
this.emit('wake', String(phrase));
|
|
return true;
|
|
}
|
|
|
|
pause() { this.active = false; }
|
|
resume() { this.active = true; }
|
|
close() { this.pause(); this.removeAllListeners(); }
|
|
}
|
|
|
|
/** Adapter for a local openWakeWord/sherpa bridge. The bridge receives raw
|
|
* PCM on stdin and prints one detected phrase per line on stdout. */
|
|
export class ProcessWakeEngine extends WakeEngine {
|
|
constructor({ command, ...options } = {}) { super(options); this.command = command; this.process = null; this._spawn = options.spawnImpl || spawn; }
|
|
start() {
|
|
if (this.process || !this.command) return this;
|
|
this.process = this._spawn(this.command, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
let pending = '';
|
|
this.process.stdout?.on('data', (chunk) => {
|
|
pending += String(chunk);
|
|
const lines = pending.split(/\r?\n/); pending = lines.pop() || '';
|
|
for (const line of lines) this.pushDetection(line.trim());
|
|
});
|
|
this.process.on('error', (error) => this.emit('error', error));
|
|
this.process.on('close', () => { this.process = null; });
|
|
return this;
|
|
}
|
|
push(frame) { if (this.process?.stdin?.writable) this.process.stdin.write(Buffer.from(frame)); }
|
|
pushDetection(phrase) {
|
|
const value = String(phrase || '').toLowerCase();
|
|
if (this.active && this.phrases.includes(value)) this.emit('wake', value);
|
|
}
|
|
close() { super.close(); this.process?.kill('SIGTERM'); this.process = null; }
|
|
}
|
|
|
|
export function createWakeEngine(options = {}) {
|
|
return options.command || process.env.JARVIS_WAKE_COMMAND ? new ProcessWakeEngine({ ...options, command: options.command || process.env.JARVIS_WAKE_COMMAND }) : new WakeEngine(options);
|
|
}
|
|
|
|
export function normalizeWakePhrases(value) {
|
|
return String(value || '').split(',').map((v) => v.trim().toLowerCase()).filter(Boolean);
|
|
}
|