@@ -21,7 +21,7 @@ export class PipeWireCapture extends EventEmitter {
|
||||
if (this.process) return this;
|
||||
this.process = this.spawnImpl(this.command, [
|
||||
'--record', '--raw', '--format', MIC_FORMAT, '--rate', String(this.sampleRate),
|
||||
'--channels', String(MIC_CHANNELS), '--name', this.nodeName,
|
||||
'--channels', String(MIC_CHANNELS), '--properties', `node.name=${this.nodeName}`, '-',
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
this.process.stdout?.on('data', (chunk) => this.emit('audio', Buffer.from(chunk)));
|
||||
this.process.stderr?.on('data', (chunk) => this.emit('diagnostic', String(chunk).trim()));
|
||||
|
||||
@@ -7,11 +7,15 @@ export class PipeWirePlayback extends EventEmitter {
|
||||
}
|
||||
async play(samples) {
|
||||
this.stop();
|
||||
const child = this.process = this.spawnImpl(this.command, ['--playback', '--raw', '--format', 's16', '--rate', String(this.sampleRate), '--channels', '1', '--name', this.nodeName], { stdio: ['pipe', 'ignore', 'pipe'] });
|
||||
child.stderr?.on('data', (chunk) => this.emit('diagnostic', String(chunk).trim()));
|
||||
child.on('error', (error) => this.emit('error', error));
|
||||
child.stdin.end(Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength));
|
||||
await new Promise((resolve, reject) => { child.once('close', resolve); child.once('error', reject); });
|
||||
const child = this.process = this.spawnImpl(this.command, ['--playback', '--raw', '--format', 's16', '--rate', String(this.sampleRate), '--channels', '1', '--properties', `node.name=${this.nodeName}`, '-'], { stdio: ['pipe', 'ignore', 'pipe'] });
|
||||
let diagnostic = '';
|
||||
child.stderr?.on('data', (chunk) => { diagnostic = (diagnostic + String(chunk)).slice(-2048); this.emit('diagnostic', String(chunk).trim()); });
|
||||
await new Promise((resolve, reject) => {
|
||||
child.once('close', (code, signal) => code === 0 || signal === 'SIGTERM' ? resolve() : reject(new Error(`Audio playback exited with code ${code}: ${diagnostic.trim()}`)));
|
||||
child.once('error', reject);
|
||||
child.stdin.on('error', reject);
|
||||
child.stdin.end(Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength));
|
||||
});
|
||||
if (this.process === child) this.process = null;
|
||||
}
|
||||
stop() { if (this.process) { this.process.kill('SIGTERM'); this.process = null; } }
|
||||
|
||||
+7
-3
@@ -7,6 +7,7 @@ import { cancelQvac, resumeQvac, suspendQvac, callQvac, cancelQvacRequest, qvacS
|
||||
import { PrivacyLog } from './privacy-log.js';
|
||||
import { VoiceLoop } from './voice-loop.js';
|
||||
import { QvacVoiceAdapter } from './voice-adapters.js';
|
||||
import { voiceSettings } from './voice-settings.js';
|
||||
import { createWakeEngine } from './wake-engine.js';
|
||||
import { DesktopObserver } from '../computer-use/observer.js';
|
||||
import { QvacPerception } from './perception.js';
|
||||
@@ -88,15 +89,18 @@ export class JarvisDaemon extends EventEmitter {
|
||||
computerRevoke() { this.input.revoke(); this.computer.revoke(); this.emit('ComputerStep', JSON.stringify({ action: 'revoke' })); }
|
||||
async startVoice() {
|
||||
if (this.voiceLoop) return;
|
||||
const voiceIO = new QvacVoiceAdapter();
|
||||
const loop = new VoiceLoop({ daemon: this, wake: createWakeEngine(), asr: voiceIO, tts: voiceIO });
|
||||
const settings = voiceSettings();
|
||||
const asr = new QvacVoiceAdapter({ role: 'asr' });
|
||||
const tts = settings.ttsEnabled ? new QvacVoiceAdapter({ role: 'tts' }) : null;
|
||||
const loop = new VoiceLoop({ daemon: this, wake: createWakeEngine(settings), asr, tts });
|
||||
loop.on('error', (error) => { console.error(`jarvisd: voice: ${error.message}`); this.emit('Error', 'VOICE', error.message); });
|
||||
this.voiceLoop = loop;
|
||||
try { await loop.start(); this.emit('StateChanged', this.state); } catch (error) {
|
||||
this.voiceLoop = null; await loop.stop?.().catch(() => {}); this.emit('Error', 'VOICE_UNAVAILABLE', error.message); throw error;
|
||||
}
|
||||
}
|
||||
setPushToTalk(pressed) { this.voiceLoop?.setPushToTalk(pressed); this.emit('PushToTalk', Boolean(pressed)); }
|
||||
runtimeStatus() { return JSON.stringify({ local: true, qvac: qvacStatus(), scheduler: this.scheduler.status(), scheduler_metrics: this.scheduler.metrics(), telemetry: this.telemetry.snapshot(), computer: this.computer.status(), voice: this.voiceLoop?.metrics?.snapshot?.() || null, p2p: { enabled: process.env.JARVIS_P2P_ENABLE === '1', inference: false, memorySync: false } }); }
|
||||
runtimeStatus() { return JSON.stringify({ local: true, qvac: qvacStatus(), scheduler: this.scheduler.status(), scheduler_metrics: this.scheduler.metrics(), telemetry: this.telemetry.snapshot(), computer: this.computer.status(), voice: this.voiceLoop ? { ...this.voiceLoop.metrics.snapshot(), ...this.voiceLoop.status } : null, p2p: { enabled: process.env.JARVIS_P2P_ENABLE === '1', inference: false, memorySync: false } }); }
|
||||
async assessModelFit(model) { return JSON.stringify(await callQvac('assessModelFit', { modelSrc: String(model) })); }
|
||||
async downloadModel(model) { return JSON.stringify(await callQvac('downloadAsset', { modelSrc: String(model) })); }
|
||||
async cancelModel(model) { return JSON.stringify(await cancelQvacRequest({ modelId: String(model) })); }
|
||||
|
||||
@@ -43,7 +43,8 @@ export function assertSdkVersion() {
|
||||
return sdkPackage.version;
|
||||
}
|
||||
|
||||
export async function acquireQvac() {
|
||||
export async function acquireQvac({ auxiliaryOnly = false } = {}) {
|
||||
if (auxiliaryOnly) { await qvacSdk(); ownerCount += 1; return; }
|
||||
ownerCount += 1;
|
||||
if (!loadPromise) {
|
||||
assertSdkVersion();
|
||||
|
||||
+3
-2
@@ -25,6 +25,7 @@ export class VadSegmenter extends EventEmitter {
|
||||
push(frame) {
|
||||
const chunk = Buffer.from(frame || '');
|
||||
const rms = pcmRms(chunk);
|
||||
const durationMs = chunk.length / 2 / this.sampleRate * 1000;
|
||||
const voiced = rms >= this.params.threshold / 10; // PCM RMS is 0..1; QVAC threshold is posterior-like.
|
||||
this.emit('level', rms);
|
||||
if (!this.speaking && voiced) {
|
||||
@@ -33,8 +34,8 @@ export class VadSegmenter extends EventEmitter {
|
||||
}
|
||||
if (!this.speaking) return;
|
||||
this.buffer.push(chunk);
|
||||
if (voiced) { this.speechMs += this.frameMs; this.silenceMs = 0; }
|
||||
else { this.silenceMs += this.frameMs; }
|
||||
if (voiced) { this.speechMs += durationMs; this.silenceMs = 0; }
|
||||
else { this.silenceMs += durationMs; }
|
||||
if (this.speechMs >= this.params.maxSpeechDurationMs ||
|
||||
(this.speechMs >= this.params.minSpeechDurationMs && this.silenceMs >= this.params.minSilenceDurationMs)) {
|
||||
this.end();
|
||||
|
||||
+11
-12
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events';
|
||||
import { acquireQvac, releaseQvac, loadAuxiliaryModel, unloadAuxiliaryModel, qvacSdk, withQvacMaster, assertSdkVersion } from './qvac-master.js';
|
||||
|
||||
export function pcmS16le(samples, sampleRate = 44_100) {
|
||||
if (Array.isArray(samples)) return { samples: Int16Array.from(samples), sampleRate };
|
||||
if (samples instanceof Int16Array) return { samples, sampleRate };
|
||||
const bytes = toUint8(samples);
|
||||
const even = bytes.byteLength - (bytes.byteLength % 2);
|
||||
@@ -19,35 +20,33 @@ function toUint8(samples) {
|
||||
}
|
||||
|
||||
export class QvacVoiceAdapter extends EventEmitter {
|
||||
constructor({ asrModel = process.env.JARVIS_ASR_MODEL || 'WHISPER_TINY', ttsModel = process.env.JARVIS_TTS_MODEL || 'TTS_EN_SUPERTONIC_Q8_0' } = {}) {
|
||||
super(); this.asrModel = asrModel; this.ttsModel = ttsModel; this.asrId = null; this.ttsId = null; this.asrSession = null; this.acquired = false;
|
||||
constructor({ role = 'both', asrModel = process.env.JARVIS_ASR_MODEL || 'WHISPER_TINY', ttsModel = process.env.JARVIS_TTS_MODEL || 'TTS_EN_SUPERTONIC_Q8_0' } = {}) {
|
||||
super(); this.role = role; this.asrModel = asrModel; this.ttsModel = ttsModel; this.asrId = null; this.ttsId = null; this.asrSession = null; this.acquired = false;
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.acquired) return;
|
||||
assertSdkVersion();
|
||||
await acquireQvac(); this.acquired = true;
|
||||
await acquireQvac({ auxiliaryOnly: true }); this.acquired = true;
|
||||
try {
|
||||
this.asrId = await loadAuxiliaryModel(this.asrModel, { vadModelSrc: 'VAD_SILERO_5_1_2', audio_format: 's16le', language: 'en', no_timestamps: true, vad_params: { threshold: 0.6, min_speech_duration_ms: 300, min_silence_duration_ms: 700, max_speech_duration_s: 15, speech_pad_ms: 200 } }, 'whisper');
|
||||
this.ttsId = await loadAuxiliaryModel(this.ttsModel, { ttsEngine: 'supertonic', language: 'en', voice: 'F1', ttsSpeed: 1.05, ttsNumInferenceSteps: 5 }, 'tts');
|
||||
if (this.role !== 'tts') this.asrId = await loadAuxiliaryModel(this.asrModel, { vadModelSrc: 'VAD_SILERO_5_1_2', audio_format: 's16le', language: 'en', no_timestamps: true, vad_params: { threshold: 0.6, min_speech_duration_ms: 300, min_silence_duration_ms: 700, max_speech_duration_s: 15, speech_pad_ms: 200 } }, 'whisper');
|
||||
if (this.role !== 'asr') this.ttsId = await loadAuxiliaryModel(this.ttsModel, { ttsEngine: 'supertonic', language: 'en', voice: 'F1', ttsSpeed: 1.05, ttsNumInferenceSteps: 5 }, 'tts');
|
||||
} catch (error) { await this.stop(); throw error; }
|
||||
}
|
||||
|
||||
writeAudio(chunk) { this.asrSession?.write(Buffer.from(chunk)); }
|
||||
async transcribeAudio(audio) {
|
||||
if (!this.asrId) throw new Error('Speech recognition is unavailable');
|
||||
const sdk = await qvacSdk();
|
||||
return withQvacMaster(async () => {
|
||||
const session = await sdk.transcribeStream({ modelId: this.asrId, emitVadEvents: true });
|
||||
session.write(Buffer.from(audio)); session.end();
|
||||
const parts = [];
|
||||
for await (const event of session) parts.push(typeof event === 'string' ? event : event?.text || '');
|
||||
return parts.join(' ').trim();
|
||||
});
|
||||
// Local VAD has already bounded this utterance. Avoid a second streaming
|
||||
// VAD gate, which can discard a complete short push-to-talk recording.
|
||||
return withQvacMaster(() => sdk.transcribe({ modelId: this.asrId, audioChunk: Buffer.from(audio) }));
|
||||
}
|
||||
async *transcripts() { if (!this.asrSession) throw new Error('voice adapter is not started'); yield* this.asrSession; }
|
||||
endAudio() { this.asrSession?.end(); }
|
||||
|
||||
async speak(text) {
|
||||
if (!this.ttsId) throw new Error('Speech output is unavailable');
|
||||
const sdk = await qvacSdk();
|
||||
const samples = await withQvacMaster(async () => {
|
||||
const result = await sdk.textToSpeech({ modelId: this.ttsId, text: String(text), inputType: 'text', stream: false });
|
||||
|
||||
+25
-7
@@ -24,17 +24,34 @@ export class VoiceLoop extends EventEmitter {
|
||||
super(); this.daemon = daemon; this.capture = capture; this.playback = playback; this.wake = wake; this.vad = vad; this.asr = asr; this.tts = tts; this.cooldownMs = cooldownMs; this.now = now;
|
||||
this.isSpeaking = false; this.cooldownUntil = 0; this.running = false; this.ptt = false; this._generation = 0; this._speechQueue = Promise.resolve(); this.metrics = new VoiceMetrics();
|
||||
capture.on('audio', (chunk) => this.pushAudio(chunk));
|
||||
capture.on('error', (error) => this.emit('error', error));
|
||||
capture.on('error', (error) => { this.status.capture = false; this.status.errors.capture = error.message; this.emit('error', error); });
|
||||
capture.on('close', () => { this.status.capture = false; if (this.running) { this.status.errors.capture = 'Microphone stream closed'; this.emit('error', new Error('Microphone stream closed')); } });
|
||||
wake.on('unavailable', () => { this.status.wake = false; });
|
||||
wake.on('error', (error) => { this.status.wake = false; this.emit('error', error); });
|
||||
this.status = { asr: false, tts: false, capture: false, wake: false, errors: {} };
|
||||
wake.on('wake', (phrase) => this.wakeHeard(phrase));
|
||||
vad.on('level', (rms) => daemon?.emit('ListeningLevel', rms));
|
||||
vad.on('utterance', (audio) => this.transcribe(audio));
|
||||
vad.on('utterance', (audio) => this.transcribe(audio).catch((error) => this.emit('error', error)));
|
||||
}
|
||||
|
||||
async start() { if (this.running) return; await this.asr?.start?.(); await this.tts?.start?.(); this.running = true; this.capture.start(); this.wake.start?.(); this.wake.resume(); }
|
||||
async stop() { this.running = false; this.capture.stop(); this.wake.close(); this.vad.reset(); this.playback.stop(); await this.asr?.stop?.(); await this.tts?.stop?.(); }
|
||||
setPushToTalk(pressed) { this.ptt = Boolean(pressed); if (this.ptt) this.wakeHeard('push-to-talk'); else this.vad.end(); }
|
||||
async start() {
|
||||
if (this.running) return;
|
||||
for (const [name, adapter] of [['tts', this.tts], ['asr', this.asr]]) {
|
||||
try { if (adapter) { await adapter.start?.(); this.status[name] = true; } }
|
||||
catch (error) { this.status.errors[name] = error.message; this.emit('error', new Error(`${name.toUpperCase()}: ${error.message}`)); }
|
||||
}
|
||||
this.running = true;
|
||||
if (this.status.asr) {
|
||||
try { this.capture.start(); this.status.capture = true; }
|
||||
catch (error) { this.status.errors.capture = error.message; this.emit('error', error); }
|
||||
try { this.wake.start?.(); this.wake.resume(); this.status.wake = Boolean(this.wake.command || this.wake.detect); }
|
||||
catch (error) { this.status.errors.wake = error.message; this.emit('error', error); }
|
||||
}
|
||||
}
|
||||
async stop() { this.running = false; this.capture.stop(); this.wake.close(); this.vad.reset(); this.playback.stop(); await this.asr?.stop?.(); if (this.tts !== this.asr) await this.tts?.stop?.(); }
|
||||
setPushToTalk(pressed) { this.ptt = Boolean(pressed) && this.status.asr && this.status.capture; if (this.ptt) this.wakeHeard('push-to-talk'); else this.vad.end(); }
|
||||
pushAudio(chunk) {
|
||||
if (!this.running) return;
|
||||
if (!this.running || this.daemon?.locked || this.daemon?.state === 'SLEEPING') return;
|
||||
if (this.isSpeaking || this.now() < this.cooldownUntil) { this.metrics.feedbackDrop(); return; }
|
||||
this.daemon?.emit('ListeningLevel', Math.min(1, Math.max(0, chunk.length ? 0.01 : 0)));
|
||||
if (!this.ptt) this.wake.push(chunk);
|
||||
@@ -45,8 +62,9 @@ export class VoiceLoop extends EventEmitter {
|
||||
this.metrics.wakeAccepted(); this.daemon?.emit('WakeHeard', phrase); this.daemon?.arm?.(); this.emit('wake', phrase);
|
||||
}
|
||||
async transcribe(audio) {
|
||||
if (!this.asr?.transcribeAudio) return;
|
||||
if (!this.status.asr || !this.asr?.transcribeAudio) return;
|
||||
const text = await this.asr.transcribeAudio(audio).catch((error) => { this.emit('error', error); return ''; });
|
||||
if (!this.running || this.daemon?.locked) return;
|
||||
if (!isMeaningfulTranscript(text)) { this.metrics.wakeRejected(); return; }
|
||||
this.metrics.utterance();
|
||||
this.daemon?.emit('PartialTranscript', text); this.daemon?.emit('FinalTranscript', text);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
export function voiceSettings() {
|
||||
let config = {};
|
||||
try { config = JSON.parse(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'jarvis/config.json'), 'utf8')); } catch {}
|
||||
return {
|
||||
command: process.env.JARVIS_WAKE_COMMAND || config.wake_command || '',
|
||||
phrases: [config.wake_phrase || 'hey jarvis', 'jarvis', 'okay jarvis'],
|
||||
ttsEnabled: config.tts_enabled !== false,
|
||||
};
|
||||
}
|
||||
@@ -45,10 +45,12 @@ export class ProcessWakeEngine extends WakeEngine {
|
||||
for (const line of lines) this.pushDetection(line.trim());
|
||||
});
|
||||
this.process.on('error', (error) => this.emit('error', error));
|
||||
this.process.on('close', () => { this.process = null; });
|
||||
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.process?.stdin?.writable) this.process.stdin.write(Buffer.from(frame)); }
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user