Files
gnome-jarvis/daemon/wake-engine.js
T
snxraven f26204505e
Rolling release / release (push) Successful in 7m21s
Updates
2026-09-12 09:40:42 -04:00

181 lines
6.3 KiB
JavaScript

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