import { EventEmitter } from 'node:events'; import { spawn } from 'node:child_process'; import path from 'node:path'; export const BUILTIN_WAKE_COMMAND = 'jarvis-wake-bridge'; const FRAME_BYTES = 640; const SPEECH_RMS = 0.018; const SILENCE_FRAMES = 8; const MIN_SPEECH_FRAMES = 16; const MAX_SPEECH_FRAMES = 90; const WAKE_COOLDOWN_MS = 1500; /** * Wake engines consume PCM frames. A production wake model can be attached * through `detect(frame)` or a stdin bridge. The built-in CPU detector looks * for a short two-to-four-peak cadence matching “hey jarvis” without loading * Whisper or any GPU model. */ 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(); } } function frameRms(buf) { const samples = buf.length / 2; if (!samples) return 0; let sum = 0; for (let i = 0; i < buf.length; i += 2) { const sample = buf.readInt16LE(i) / 32768; sum += sample * sample; } return Math.sqrt(sum / samples); } function countPeaks(series) { const smooth = series.map((value, index, items) => { const prev = items[index - 1] ?? value; const next = items[index + 1] ?? value; return (prev + value + next) / 3; }); const max = Math.max(0, ...smooth); if (max < SPEECH_RMS) return 0; const floor = Math.max(SPEECH_RMS * 1.2, max * 0.45); let peaks = 0; let raised = false; for (let i = 1; i < smooth.length - 1; i++) { if (smooth[i] >= floor && smooth[i] >= smooth[i - 1] && smooth[i] >= smooth[i + 1]) { if (!raised) { peaks += 1; raised = true; } } else if (smooth[i] < floor * 0.65) { raised = false; } } return peaks; } /** CPU keyword spotter. Keeps GPU ASR unloaded until a real wake. */ export class KeywordWakeEngine extends WakeEngine { constructor(options = {}) { super(options); this.command = options.command || BUILTIN_WAKE_COMMAND; this.detect = (frame) => this._ingest(frame); this._pending = Buffer.alloc(0); this._rms = []; this._silence = 0; this._lastWake = 0; } start() { this.active = true; return this; } push(frame) { if (!this.active) return false; return this._ingest(frame); } _ingest(frame) { this._pending = Buffer.concat([this._pending, Buffer.from(frame)]); let woke = false; while (this._pending.length >= FRAME_BYTES) { const next = this._pending.subarray(0, FRAME_BYTES); this._pending = this._pending.subarray(FRAME_BYTES); if (this._acceptFrame(next)) woke = true; } return woke; } _acceptFrame(frame) { const rms = frameRms(frame); if (rms >= SPEECH_RMS) { this._rms.push(rms); this._silence = 0; if (this._rms.length > MAX_SPEECH_FRAMES + SILENCE_FRAMES) this._rms.shift(); return false; } if (!this._rms.length) return false; this._rms.push(rms); this._silence += 1; if (this._silence < SILENCE_FRAMES) return false; const series = this._rms; this._rms = []; this._silence = 0; return this._maybeWake(series); } _maybeWake(series) { const voiced = series.filter((value) => value >= SPEECH_RMS).length; if (voiced < MIN_SPEECH_FRAMES || voiced > MAX_SPEECH_FRAMES) return false; const peaks = countPeaks(series); if (peaks < 2 || peaks > 4) return false; const now = Date.now(); if (now - this._lastWake < WAKE_COOLDOWN_MS) return false; const phrase = this.phrases[0] || 'hey jarvis'; this._lastWake = now; this.emit('wake', phrase); return true; } } /** 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 isBuiltinWakeCommand(command) { const value = String(command || '').trim(); if (!value) return false; const executable = value.split(/\s+/)[0]; const base = path.basename(executable); return value === BUILTIN_WAKE_COMMAND || base === BUILTIN_WAKE_COMMAND || base === 'wake-bridge.js'; } export function createWakeEngine(options = {}) { const command = options.command || process.env.JARVIS_WAKE_COMMAND || ''; if (isBuiltinWakeCommand(command)) return new KeywordWakeEngine({ ...options, command: BUILTIN_WAKE_COMMAND }); if (command) return new ProcessWakeEngine({ ...options, command }); return new WakeEngine(options); } export function normalizeWakePhrases(value) { return String(value || '').split(',').map((v) => v.trim().toLowerCase()).filter(Boolean); }