@@ -11,7 +11,28 @@ import { createPhase9GatewayTool } from '../skills/phase9-tools.js';
|
||||
export class HarnessBridge extends EventEmitter {
|
||||
constructor({ cwd = process.cwd(), model = 'qwen3.5-4b', tools = [], computer, observer, actuator, permissionMode = 'ask' } = {}) {
|
||||
super();
|
||||
this.options = { cwd, model, tools: [...createRuntimeTools({ computer }), ...createPhase2Tools({ cwd, computer }), ...createComputerObserveTools({ computer, observer }), ...createComputerActTools({ actuator }), ...createQvacTools(), ...createPhase9GatewayTool(), ...tools], permissionMode, origin: 'jarvis-qvac', system: VOICE_SYSTEM_PROMPT };
|
||||
this.options = {
|
||||
cwd,
|
||||
model,
|
||||
tools: [
|
||||
...createRuntimeTools({ computer }),
|
||||
...createPhase2Tools({ cwd, computer }),
|
||||
...createComputerObserveTools({ computer, observer }),
|
||||
...createComputerActTools({ actuator }),
|
||||
...createQvacTools(),
|
||||
...createPhase9GatewayTool(),
|
||||
...tools,
|
||||
],
|
||||
builtinTools: ['read_file', 'list_dir', 'grep', 'run_terminal_cmd', 'web_fetch', 'web_search'],
|
||||
webFetch: true,
|
||||
permissionMode,
|
||||
origin: 'jarvis-qvac',
|
||||
system: VOICE_SYSTEM_PROMPT,
|
||||
voice: true,
|
||||
maxTurns: 6,
|
||||
maxShellCalls: 1,
|
||||
maxToolRounds: 4,
|
||||
};
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
|
||||
+11
-8
@@ -102,21 +102,24 @@ export class JarvisDaemon extends EventEmitter {
|
||||
if (this.state === 'SPEAKING') this.setState('LISTENING');
|
||||
}
|
||||
_speakReply(spoken) {
|
||||
if (!this.voiceLoop) {
|
||||
this._finishSpeech();
|
||||
return;
|
||||
}
|
||||
const play = async () => {
|
||||
await this.ensureTts();
|
||||
const playing = this.voiceLoop?.speak?.(spoken);
|
||||
if (!playing) {
|
||||
if (!this.voiceLoop?.status?.tts) {
|
||||
const reason = this.voiceLoop?.status?.errors?.tts;
|
||||
if (reason && reason !== 'Spoken replies are disabled') this.emit('Error', 'TTS', reason);
|
||||
this._finishSpeech();
|
||||
return;
|
||||
}
|
||||
await playing;
|
||||
await this.voiceLoop.speak(spoken);
|
||||
};
|
||||
Promise.resolve(play()).catch((error) => {
|
||||
this.emit('Error', 'TTS', error.message);
|
||||
if (this.state === 'SPEAKING') this._finishSpeech();
|
||||
});
|
||||
Promise.resolve(play())
|
||||
.catch((error) => {
|
||||
this.emit('Error', 'TTS', error.message);
|
||||
})
|
||||
.finally(() => this._finishSpeech());
|
||||
}
|
||||
async ensureTts() {
|
||||
if (!this.voiceLoop) return;
|
||||
|
||||
@@ -1,5 +1,62 @@
|
||||
export const MIN_UTTERANCE_CHARS = 3;
|
||||
|
||||
const SPOKEN_ACRONYMS = {
|
||||
qvac: 'Quantum Verse Automatic Computer',
|
||||
cpu: 'C P U',
|
||||
gpu: 'G P U',
|
||||
ram: 'R A M',
|
||||
ssd: 'S S D',
|
||||
hdd: 'H D D',
|
||||
usb: 'U S B',
|
||||
dns: 'D N S',
|
||||
isp: 'I S P',
|
||||
vpn: 'V P N',
|
||||
ssh: 'S S H',
|
||||
api: 'A P I',
|
||||
url: 'U R L',
|
||||
uri: 'U R I',
|
||||
http: 'H T T P',
|
||||
https: 'H T T P S',
|
||||
html: 'H T M L',
|
||||
json: 'J S O N',
|
||||
xml: 'X M L',
|
||||
os: 'O S',
|
||||
ip: 'I P',
|
||||
tts: 'T T S',
|
||||
asr: 'A S R',
|
||||
hud: 'heads up display',
|
||||
llm: 'L L M',
|
||||
cli: 'C L I',
|
||||
gui: 'G U I',
|
||||
ptt: 'P T T',
|
||||
vad: 'V A D',
|
||||
};
|
||||
|
||||
function spellAcronyms(text) {
|
||||
return String(text || '').replace(/\b([A-Za-z]{2,6})\b/g, (word) => {
|
||||
const spoken = SPOKEN_ACRONYMS[word.toLowerCase()];
|
||||
return spoken || word;
|
||||
});
|
||||
}
|
||||
|
||||
function speakableAddresses(text) {
|
||||
return String(text || '')
|
||||
.replace(/\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b/g, '$1 $2 $3 $4')
|
||||
.replace(/https?:\/\//gi, '')
|
||||
.replace(/\b([A-Za-z0-9-]+)\.(com|org|net|io|dev|local|lan)\b/gi, (_, host, tld) => `${host} dot ${tld}`)
|
||||
.replace(/\//g, ' slash ')
|
||||
.replace(/@/g, ' at ')
|
||||
.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export function speakableForTts(text) {
|
||||
return spellAcronyms(speakableAddresses(String(text || '')))
|
||||
.replace(/[`]/g, "'")
|
||||
.replace(/[<>]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function isSpeakable(text) {
|
||||
const value = String(text || '').trim();
|
||||
if (!value || value === '[object Object]') return false;
|
||||
|
||||
@@ -49,7 +49,7 @@ export class QvacVoiceAdapter extends EventEmitter {
|
||||
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 });
|
||||
const result = await sdk.textToSpeech({ modelId: this.ttsId, text: String(text).replace(/[`]/g, "'"), inputType: 'text', stream: false });
|
||||
if (result?.buffer != null) return await result.buffer;
|
||||
if (result?.bufferStream) {
|
||||
const chunks = [];
|
||||
|
||||
+32
-13
@@ -3,7 +3,7 @@ import { PipeWireCapture } from './audio-pipewire.js';
|
||||
import { PipeWirePlayback } from './audio-playback.js';
|
||||
import { WakeEngine } from './wake-engine.js';
|
||||
import { VadSegmenter } from './vad.js';
|
||||
import { isMeaningfulTranscript, isSpeakable, SentenceBuffer } from './transcript.js';
|
||||
import { isMeaningfulTranscript, isSpeakable, speakableForTts, SentenceBuffer } from './transcript.js';
|
||||
import { VoiceMetrics } from './voice-metrics.js';
|
||||
|
||||
export const POST_PLAYBACK_COOLDOWN_MS = 400;
|
||||
@@ -91,29 +91,48 @@ export class VoiceLoop extends EventEmitter {
|
||||
_releaseSpeaking() {
|
||||
this.isSpeaking = false;
|
||||
this.wake.resume();
|
||||
if (this.daemon?.state === 'SPEAKING') { this.daemon.voice?.finishSpeaking?.(); this.daemon.setState?.('LISTENING'); }
|
||||
if (this.daemon?.state === 'SPEAKING') {
|
||||
try { this.daemon.voice?.finishSpeaking?.(); } catch {}
|
||||
this.daemon.setState?.('LISTENING');
|
||||
}
|
||||
}
|
||||
async speak(text) {
|
||||
const generation = this._generation || 0;
|
||||
if (!isSpeakable(text) || !this.tts?.speak || !this.status.tts) { this._releaseSpeaking(); return; }
|
||||
if (!isSpeakable(text) || !this.tts?.speak || !this.status.tts) {
|
||||
this._releaseSpeaking();
|
||||
return;
|
||||
}
|
||||
this.metrics.reply();
|
||||
const sentences = String(text).match(/[^.!?]+[.!?]+|[^.!?]+$/g)?.map((part) => part.trim()).filter(isSpeakable) || [];
|
||||
if (!sentences.length) { this._releaseSpeaking(); return; }
|
||||
if (!sentences.length) {
|
||||
this._releaseSpeaking();
|
||||
return;
|
||||
}
|
||||
this._speechQueue = this._speechQueue.catch(() => {}).then(async () => {
|
||||
if (generation !== this._generation) return;
|
||||
for (const sentence of sentences) {
|
||||
try {
|
||||
if (generation !== this._generation) return;
|
||||
await this.speakSentence(sentence);
|
||||
for (const sentence of sentences) {
|
||||
if (generation !== this._generation) return;
|
||||
await this.speakSentence(sentence, generation);
|
||||
}
|
||||
} finally {
|
||||
if (generation === this._generation) this._releaseSpeaking();
|
||||
}
|
||||
});
|
||||
return this._speechQueue;
|
||||
}
|
||||
async speakSentence(text) {
|
||||
this.isSpeaking = true; this.wake.pause(); this.daemon?.emit('StateChanged', 'SPEAKING');
|
||||
try { const audio = await this.tts.speak(text); await this.playback.play(audio.samples); try { this.daemon?.emit('SpeakingLevel', 0); } catch {} }
|
||||
finally {
|
||||
this.isSpeaking = false; this.cooldownUntil = this.now() + this.cooldownMs; this.wake.resume();
|
||||
if (this.daemon?.state === 'SPEAKING') { this.daemon.voice?.finishSpeaking?.(); this.daemon.setState?.('LISTENING'); }
|
||||
async speakSentence(text, generation) {
|
||||
this.isSpeaking = true;
|
||||
this.wake.pause();
|
||||
try {
|
||||
const audio = await this.tts.speak(speakableForTts(text));
|
||||
if (generation !== this._generation) return;
|
||||
await this.playback.play(audio.samples);
|
||||
try { this.daemon?.emit('SpeakingLevel', 0); } catch {}
|
||||
} finally {
|
||||
this.isSpeaking = false;
|
||||
this.cooldownUntil = this.now() + this.cooldownMs;
|
||||
this.wake.resume();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user