181 lines
6.3 KiB
JavaScript
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);
|
|
}
|