Files
gnome-jarvis/daemon/wake-engine.js
T
2026-09-11 14:09:04 -04:00

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);
}