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.stderr?.on('data', () => {}); this.process.stdin?.on('error', (error) => this.emit('error', error)); this.process.on('close', () => { this.process = null; this.emit('unavailable'); }); return this; } push(frame) { if (this.active && 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); }