+118
-5
@@ -1,12 +1,20 @@
|
||||
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)`, 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.
|
||||
* 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 } = {}) {
|
||||
@@ -31,6 +39,100 @@ export class WakeEngine extends EventEmitter {
|
||||
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 {
|
||||
@@ -58,8 +160,19 @@ export class ProcessWakeEngine extends WakeEngine {
|
||||
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 = {}) {
|
||||
return options.command || process.env.JARVIS_WAKE_COMMAND ? new ProcessWakeEngine({ ...options, command: options.command || process.env.JARVIS_WAKE_COMMAND }) : new WakeEngine(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) {
|
||||
|
||||
Reference in New Issue
Block a user