Updates
Rolling release / release (push) Failing after 1m24s

This commit is contained in:
2026-09-11 19:36:12 -04:00
parent 7604cded2c
commit a78708f8ff
26 changed files with 298 additions and 59 deletions
+9 -1
View File
@@ -17,8 +17,16 @@ jobs:
cache: npm
- run: npm ci
- run: npm test
- run: bash packaging/bare-launch.sh packaging/bare-run.js packaging/voice-runtime-smoke.js
- run: python3 -m py_compile apps/control-center/main.py computer-use/py/*.py
- run: npm run package
- name: Build and verify packaged speech roundtrip
env:
JARVIS_VERIFY_VOICE_MODELS: '1'
QVAC_CONFIG_PATH: /tmp/jarvis-ci-qvac.json
run: |
mkdir -p /tmp/jarvis-ci-models
printf '%s\n' '{"cacheDirectory":"/tmp/jarvis-ci-models"}' > "$QVAC_CONFIG_PATH"
npm run package
- name: Publish rolling prerelease to Gitea
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
+30 -5
View File
@@ -18,13 +18,17 @@ class Store:
self.data[key] = value; CONFIG.parent.mkdir(parents=True, exist_ok=True); CONFIG.write_text(json.dumps(self.data, indent=2) + '\n')
class ControlCenter(Adw.Application):
def __init__(self): super().__init__(application_id='io.qvac.Jarvis.Control', flags=Gio.ApplicationFlags.DEFAULT); self.store = Store(); self.rows = {}; self.proxy = None
def __init__(self): super().__init__(application_id='io.qvac.Jarvis.Control', flags=Gio.ApplicationFlags.DEFAULT_FLAGS); self.store = Store(); self.rows = {}; self.proxy = None
def do_activate(self):
if getattr(self, 'window', None): self.window.present(); return
self.window = Adw.ApplicationWindow(application=self, title='Jarvis Control Center', default_width=1080, default_height=700)
self.window.set_content(self.build_shell()); self.connect_daemon(); self.window.present()
def build_shell(self):
split = Adw.NavigationSplitView(); sidebar = Adw.NavigationPage(title='Jarvis', child=self.build_sidebar(split)); content = Adw.NavigationPage(title='General', child=self.build_page('general')); split.set_sidebar(sidebar); split.set_content(content); self.content = content; self.split = split; return split
split = Adw.NavigationSplitView(); sidebar = Adw.NavigationPage(title='Jarvis', child=self.build_sidebar(split)); content = Adw.NavigationPage(title='General', child=self.build_page('general')); split.set_sidebar(sidebar); split.set_content(content); self.content = content; self.split = split
shell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
header = Adw.HeaderBar()
header.set_title_widget(Adw.WindowTitle(title='Jarvis', subtitle='Local voice assistant'))
shell.append(header); split.set_vexpand(True); shell.append(split); return shell
def build_sidebar(self, split):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12); box.set_margin_top(18); box.set_margin_bottom(18); box.set_margin_start(12); box.set_margin_end(12)
heading = Gtk.Label(label='JARVIS', xalign=0); heading.add_css_class('title-2'); box.append(heading)
@@ -34,7 +38,9 @@ class ControlCenter(Adw.Application):
row = Gtk.ListBoxRow(); row.set_child(Gtk.Label(label=label, xalign=0, margin_top=10, margin_bottom=10, margin_start=12, margin_end=12)); row.set_name(key); listbox.append(row)
listbox.connect('row-selected', lambda _list, row: self.select_page(split, row.get_name() if row else 'general')); listbox.select_row(listbox.get_row_at_index(0)); box.append(listbox)
self.runtime = Gtk.Label(label='Connecting to jarvisd…', xalign=0, wrap=True); self.runtime.add_css_class('dim-label'); box.append(self.runtime); return box
def select_page(self, split, key): self.content.set_child(self.build_page(key)); self.content.set_title(dict((p[0], p[1]) for p in PAGES)[key])
def select_page(self, split, key):
if not hasattr(self, 'content'): return
self.content.set_child(self.build_page(key)); self.content.set_title(dict((p[0], p[1]) for p in PAGES)[key])
def build_page(self, key):
page = Gtk.ScrolledWindow(); page.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18); root.set_margin_top(28); root.set_margin_bottom(28); root.set_margin_start(32); root.set_margin_end(32); page.set_child(root)
title = next(p for p in PAGES if p[0] == key); header = Gtk.Label(label=title[1], xalign=0); header.add_css_class('title-1'); root.append(header); sub = Gtk.Label(label=title[2], xalign=0); sub.add_css_class('dim-label'); root.append(sub)
@@ -50,7 +56,15 @@ class ControlCenter(Adw.Application):
def general(self, root):
group = self.group(root, 'General'); self.entry(group, 'wake_phrase', 'Wake phrase', 'hey jarvis'); self.entry(group, 'language', 'Language', 'en-US'); self.entry(group, 'hotkey', 'Hotkey', '<Super><Space>'); self.switch(group, 'startup', 'Start Jarvis when I log in', True)
def voice(self, root):
group = self.group(root, 'Voice'); self.entry(group, 'voice_id', 'Voice ID'); self.entry(group, 'voice_reference', 'Reference WAV path'); self.entry(group, 'wake_command', 'Local wake bridge command'); self.switch(group, 'tts_enabled', 'Enable spoken replies', True); self.button(group, 'Voice enrollment', 'Enroll reference', lambda _b: self.call('IngestPath', '(s)', [self.store.data.get('voice_reference', '')])); self.button(group, 'Preview voice', 'Speak preview', lambda _b: self.call('Say', '(s)', ['Jarvis voice preview.'])); self.button(group, 'Wake model', 'Refresh runtime', lambda _b: self.call('GetRuntimeStatus'))
group = self.group(root, 'Speech and microphone', 'Hold Talk in the overlay to speak. Wake detection is optional. Changes apply after restarting Jarvis.')
self.voice_status = Gtk.Label(label='Checking voice readiness…', xalign=0, wrap=True)
root.append(self.voice_status)
self.entry(group, 'wake_command', 'Local wake bridge command')
self.switch(group, 'tts_enabled', 'Enable spoken replies', True)
self.button(group, 'Test your speakers', 'Speak preview', lambda _b: self.call('Say', '(s)', ['Jarvis voice preview.']))
self.button(group, 'Voice readiness', 'Refresh', lambda _b: self.call('GetRuntimeStatus'))
self.call('GetRuntimeStatus')
def models(self, root):
group = self.group(root, 'Single QVAC master'); self.model_status = Gtk.Label(label='Loading master status…', xalign=0, wrap=True); group.add(Adw.ActionRow(title='Runtime status', child=self.model_status)); self.entry(group, 'model', 'Active model', 'qwen3.5-4b'); self.button(group, 'Assess model fit', 'Assess', lambda _b: self.assess()); self.button(group, 'Model lifecycle', 'Download / resume', lambda _b: self.download()); self.button(group, 'Model lifecycle', 'Pause / cancel', lambda _b: self.call('CancelModel', '(s)', [self.store.data.get('model', 'qwen3.5-4b')]))
capabilities = self.group(root, 'Capabilities', 'Every capability remains visible when its model does not fit the GPU profile.')
@@ -81,7 +95,18 @@ class ControlCenter(Adw.Application):
try: self.update_status(proxy.call_finish(result))
except Exception as exc: self.runtime.set_text(f'{method}: {exc}')
def update_status(self, result):
value = result.unpack()[0] if result else ''; self.runtime.set_text('Connected · local QVAC master');
values = result.unpack() if result else (); value = values[0] if values else ''; self.runtime.set_text('Connected · local QVAC master');
try:
status = json.loads(value) if isinstance(value, str) else {}
voice = status.get('voice') or {}
if hasattr(self, 'voice_status') and 'voice' in status:
self.voice_status.set_text('\n'.join([
'Speech output: ' + ('ready' if voice.get('tts') else 'unavailable or disabled'),
'Microphone: ' + ('ready' if voice.get('asr') and voice.get('capture') else 'unavailable'),
'Wake phrase: ' + ('enabled' if voice.get('wake') else 'off — use Hold Talk'),
*[f'{key}: {message}' for key, message in voice.get('errors', {}).items()],
]))
except (ValueError, AttributeError): pass
if hasattr(self, 'model_status'): self.model_status.set_text(value)
def assess(self): self.call('AssessModelFit', '(s)', [self.store.data.get('model', 'qwen3.5-4b')])
def download(self): self.call('DownloadModel', '(s)', [self.store.data.get('model', 'qwen3.5-4b')])
@@ -83,7 +83,11 @@ class ArcOverlay {
this.root.add_child(this.header); this.root.add_child(this.statusLine); this.root.add_child(this.job); this.root.add_child(this.target); this.root.add_child(this.cursor); this.root.add_child(this.wave); this.root.add_child(this.scroll); this.root.add_child(this.chips);
this.controls = new St.BoxLayout({ style_class: 'jarvis-chips' });
this.talk = new St.Button({ label: 'Talk', style_class: 'jarvis-chip jarvis-talk', reactive: true, can_focus: true });
this.talk.accessible_name = 'Hold to talk';
this.talk.accessible_name = 'Hold Space or Enter to talk';
this.talk.connect('key-press-event', (_actor, event) => { if ([Clutter.KEY_space, Clutter.KEY_Return].includes(event.get_key_symbol())) { this.onTalk?.(true); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; });
this.talk.connect('key-release-event', (_actor, event) => { if ([Clutter.KEY_space, Clutter.KEY_Return].includes(event.get_key_symbol())) { this.onTalk?.(false); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; });
this.talk.connect('key-focus-out', () => this.onTalk?.(false));
this.talk.connect('leave-event', () => { this.onTalk?.(false); return Clutter.EVENT_PROPAGATE; });
this.talk.connect('button-press-event', () => { this.onTalk?.(true); return Clutter.EVENT_STOP; });
this.talk.connect('button-release-event', () => { this.onTalk?.(false); return Clutter.EVENT_STOP; });
this.controls.add_child(this.talk);
@@ -97,11 +101,22 @@ class ArcOverlay {
this.reducedMotion = false;
}
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.halo = new St.Widget({ style_class: 'jarvis-halo', reactive: false }); Main.layoutManager.addChrome(this.halo, { affectsStruts: false, trackFullscreen: false }); this.halo.hide(); this.hide(); }
show() { const monitor = Main.layoutManager.primaryMonitor; if (monitor) this.root.set_position(monitor.x + Math.max(0, Math.round((monitor.width - OVERLAY_WIDTH) / 2)), monitor.y + 64); this.root.visible = true; this.root.grab_key_focus(); }
hide() { this.root.visible = false; }
show() {
const monitor = Main.layoutManager.primaryMonitor;
if (monitor) {
const width = Math.min(OVERLAY_WIDTH, monitor.width - 80);
this.root.set_width(width);
this.root.set_position(monitor.x + Math.max(0, Math.round((monitor.width - width) / 2)), monitor.y + 48);
this.scroll.set_height(Math.max(100, Math.min(280, monitor.height - 360)));
}
const wasVisible = this.root.visible;
this.root.visible = true;
if (!wasVisible) this.entry.grab_key_focus();
}
hide() { this.onTalk?.(false); this.root.visible = false; }
toggle() { this.root.visible ? this.hide() : this.show(); }
clear() { this.transcript.destroy_all_children(); }
addRow(who, text) { const body = safeText(text); const row = new St.Label({ text: `${who === 'J' ? 'J' : 'YOU'} ${body}`, style_class: `jarvis-row jarvis-row-${who === 'J' ? 'jarvis' : 'user'}`, can_focus: true }); row.accessible_name = `${who === 'J' ? 'Jarvis' : 'You'}: ${body}`; this.transcript.add_child(row); this.show(); return row; }
addRow(who, text) { const body = safeText(text); const row = new St.Label({ text: `${who === 'J' ? 'J' : 'YOU'} ${body}`, style_class: `jarvis-row jarvis-row-${who === 'J' ? 'jarvis' : 'user'}`, can_focus: true }); row.accessible_name = `${who === 'J' ? 'Jarvis' : 'You'}: ${body}`; if (row.clutter_text) { row.clutter_text.line_wrap = true; row.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; row.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; } this.transcript.add_child(row); if (this.transcript.get_n_children() > 100) this.transcript.get_first_child().destroy(); this.show(); return row; }
token(text) { const chunk = safeText(text); if (!chunk) return; let row = this.transcript.get_last_child?.(); if (!row || !String(row.style_class || '').includes('jarvis-row-jarvis')) { row = this.addRow('J', ''); } row.text = `${row.text}${chunk}`; row.accessible_name = `Jarvis: ${row.text}`; this.show(); }
finalizeReply(text) {
const spoken = safeText(text);
@@ -149,7 +164,7 @@ export default class JarvisExtension extends Extension {
this.overlay.onMode = (mode) => this._call('SetMode', '(s)', [mode]); this.overlay.onSuggestion = (id, payload) => this._call('Ask', '(s)', [payload?.text || payload?.prompt || id]);
this._keyName = 'hotkey'; try { Main.wm.addKeybinding(this._keyName, this.settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, () => this.overlay.toggle()); } catch (error) { log(`Jarvis hotkey unavailable: ${error.message}`); }
this._settingsChanged = this.settings.connect('changed::accent-color', () => this._applyAccent()); this._applyAccent(); this._connectDaemon(); this.overlay.show();
try { this._lockChanged = Main.screenShield.connect('locked-changed', () => { if (Main.screenShield.locked) this._call('ComputerRevoke'); }); } catch {}
try { this._lockChanged = Main.screenShield.connect('locked-changed', () => { if (Main.screenShield.locked) { this.overlay.hide(); this._call('ComputerRevoke'); } }); } catch {}
}
async _connectDaemon() {
const proxy = this.proxy;
@@ -186,9 +201,18 @@ export default class JarvisExtension extends Extension {
if (!this.proxy?.owned?.() || !this.overlay) return;
try {
const runtime = await this.proxy.call('GetRuntimeStatus');
if (!this.overlay) return;
const status = JSON.parse(runtime.deep_unpack()[0] || '{}');
this.overlay.setConnectionStatus(status.voice ? 'local' : 'voice-unavailable');
} catch { this.overlay.setConnectionStatus('local'); }
const voice = status.voice;
this.overlay.setConnectionStatus(voice ? 'local' : 'voice-unavailable');
if (voice) {
const input = voice.asr && voice.capture;
this.overlay.status.text = `${voice.tts ? 'SPEECH ON' : 'SPEECH OFF'} · ${input ? (voice.wake ? 'WAKE ON' : 'HOLD TALK') : 'MIC UNAVAILABLE'}`;
this.overlay.status.accessible_name = this.overlay.status.text;
this.overlay.talk.reactive = Boolean(input); this.overlay.talk.can_focus = Boolean(input);
this.overlay.talk.label = input ? 'Hold to talk' : 'Mic unavailable';
}
} catch { this.overlay?.setConnectionStatus('voice-unavailable'); }
}
_call(name, signature, value) { this.proxy.call(name, signature, value).catch((error) => { log(`Jarvis ${name}: ${error.message}`); this.overlay?.addRow('J', `${name} failed: ${error.message}`); }); }
_setState(state) { const value = safeText(state); this._glyph.text = ({ ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' })[value] || '◯'; this._glyph.text += ' Jarvis'; this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`; this.overlay.setState(value); }
@@ -1,5 +1,5 @@
.jarvis-panel-glyph { color: #F4B942; font-size: 16px; }
.jarvis-arc { width: 720px; padding: 18px; margin-top: 0; margin-left: 20px; margin-right: 20px; spacing: 10px; border-radius: 16px; background-color: rgba(11, 14, 20, .92); border: 1px solid rgba(244, 185, 66, .42); color: #f6f7fb; box-shadow: 0 12px 40px rgba(0, 0, 0, .45); }
.jarvis-arc { width: 720px; padding: 24px; margin-top: 0; margin-left: 0; margin-right: 0; spacing: 14px; border-radius: 22px; background-color: rgba(11, 14, 20, .92); border: 1px solid rgba(244, 185, 66, .42); color: #f6f7fb; box-shadow: 0 12px 40px rgba(0, 0, 0, .45); }
.jarvis-arc-header { spacing: 8px; }
.jarvis-title { font-weight: bold; letter-spacing: 1px; }
.jarvis-local { color: var(--jarvis-accent, #F4B942); font-size: 11px; }
@@ -12,9 +12,17 @@
.jarvis-wave-bar { width: 7px; background-color: var(--jarvis-accent, #F4B942); border-radius: 4px; }
.jarvis-transcript-scroll { height: 280px; }
.jarvis-transcript { spacing: 7px; }
.jarvis-row { padding: 6px 10px; border-radius: 8px; font-size: 14px; }
.jarvis-row { padding: 12px 14px; border-radius: 8px; font-size: 14px; }
.jarvis-row-user { border-left: 2px solid var(--jarvis-accent, #F4B942); }
.jarvis-row-jarvis { border-right: 2px solid #4FD2FF; }
.jarvis-chips { spacing: 6px; }
.jarvis-chip { padding: 5px 9px; border-radius: 999px; background-color: rgba(255, 255, 255, .08); }
.jarvis-chip:hover, .jarvis-chip:focus { background-color: rgba(244, 185, 66, .25); }
.jarvis-title { font-size: 18px; }
.jarvis-row-user { background-color: rgba(244, 185, 66, .08); }
.jarvis-row-jarvis { background-color: rgba(79, 210, 255, .05); }
.jarvis-talk { background-color: #F4B942; color: #16191f; font-weight: bold; padding: 9px 18px; }
.jarvis-talk:active { background-color: #ffe09a; }
.jarvis-arc StEntry { border-radius: 12px; padding: 12px; background-color: rgba(255, 255, 255, .06); color: #f6f7fb; border: 1px solid rgba(255, 255, 255, .15); }
.jarvis-arc StEntry:focus { border-color: #F4B942; }
+1 -1
View File
@@ -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()));
+9 -5
View File
@@ -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
View File
@@ -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) })); }
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+13
View File
@@ -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,
};
}
+4 -2
View File
@@ -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);
+36
View File
@@ -42,3 +42,39 @@ very short fragments are discarded.
Run `npm run voice-doctor`, then follow [voice acceptance](voice-acceptance.md).
The daemon metrics cover wake accepts/rejects, feedback drops, utterances, and
replies. Raw audio is not persisted by default.
## Release voice readiness
ASR and TTS initialize independently in the same QVAC runtime. Voice models do
not require the chat model to load first. The runtime status includes separate
`asr`, `tts`, `capture`, and `wake` flags plus per-component errors. Without a
wake bridge, use Hold Talk (mouse, Space, or Enter) in the GNOME overlay.
The Control Center's wake command and spoken-reply setting are read from
`~/.config/jarvis/config.json` on daemon startup. Restart `jarvisd.service` after
changing them. `JARVIS_WAKE_COMMAND` overrides the saved command. A wake command
must consume 16 kHz mono signed PCM and emit detected phrases on stdout; simply
setting a phrase does not install a wake detector.
QVAC TTS numeric arrays contain signed 16-bit samples, not bytes. Supertonic
plays at 44.1 kHz. Captured utterances use the non-streaming transcription API
because local VAD already supplies utterance boundaries.
CI pins the native engines, verifies their registration inside the staged
release, and synthesizes a sentence then transcribes it with Whisper. Voice
models download into a temporary CI cache. Both the archive and Debian package
include the validated Bare runtime and native dependencies. The release gate
requires model download access; it fails rather than publishing an untested
voice bundle. Microphone routing and physical speaker output still require a
local PipeWire session and working hardware.
Run the model roundtrip locally with:
```sh
bash packaging/bare-launch.sh packaging/bare-run.js packaging/voice-model-smoke.js
```
PipeWire capture/playback use `--properties node.name=Jarvis` and `-` for
standard I/O; `pw-cat` does not accept `--name`. A local capture check is
`bash packaging/bare-launch.sh packaging/bare-run.js packaging/pipewire-smoke.js`.
Add `--play` to the model roundtrip to exercise speaker playback as well.
+4
View File
@@ -14,8 +14,12 @@
"vendor/agent-harness"
],
"dependencies": {
"@qvac/asr-ggml": "0.3.3",
"@qvac/cli": "0.13.0",
"@qvac/inference": "0.19.1",
"@qvac/llm-llamacpp": "0.49.1",
"@qvac/sdk": "0.19.1",
"@qvac/tts-ggml": "0.8.1",
"bare-node-runtime": "1.5.0",
"bare-runtime": "1.32.0",
"dbus-next": "^0.10.2"
+6 -2
View File
@@ -32,11 +32,15 @@
"bare:smoke": "bash packaging/bare-launch.sh packaging/bare-smoke.js"
},
"dependencies": {
"@qvac/asr-ggml": "0.3.3",
"@qvac/cli": "0.13.0",
"@qvac/inference": "0.19.1",
"@qvac/llm-llamacpp": "0.49.1",
"@qvac/sdk": "0.19.1",
"dbus-next": "^0.10.2",
"@qvac/tts-ggml": "0.8.1",
"bare-node-runtime": "1.5.0",
"bare-runtime": "1.32.0"
"bare-runtime": "1.32.0",
"dbus-next": "^0.10.2"
},
"imports": {
"assert": {
+6 -2
View File
@@ -13,13 +13,17 @@ Version: ${VERSION}
Section: utils
Priority: optional
Architecture: ${ARCH}
Depends: pipewire-bin, python3-gi, gir1.2-gtk-4.0, gir1.2-adw-1, gnome-shell (>= 45)
Maintainer: JARVIS-QVAC maintainers
Description: Local GPU-backed GNOME voice assistant
JARVIS-QVAC is a local-first voice assistant for Ubuntu GNOME.
EOF
tar --exclude='.git' --exclude='./.agents' --exclude='./.codex' --exclude='./dist' --exclude='./node_modules' --exclude='*/node_modules' --exclude='*/__pycache__' --exclude='*.pyc' -cf - -C "${ROOT_DIR}" . | tar -xf - -C "${PKG_DIR}/usr/lib/jarvis-qvac"
case "${ARCH}" in amd64) PLATFORM=linux-x64 ;; arm64) PLATFORM=linux-arm64 ;; *) echo "Unsupported architecture: ${ARCH}" >&2; exit 2 ;; esac
BUNDLE="${OUT_DIR}/jarvis-qvac-bare-${PLATFORM}.tar.gz"
[[ -f "${BUNDLE}" ]] || bash "${ROOT_DIR}/packaging/build-runtime-bundle.sh"
tar -xzf "${BUNDLE}" --strip-components=1 -C "${PKG_DIR}/usr/lib/jarvis-qvac"
cp "${ROOT_DIR}/README.md" "${PKG_DIR}/usr/share/doc/jarvis-qvac/README.md"
cp "${ROOT_DIR}/docs/RELEASE.md" "${PKG_DIR}/usr/share/doc/jarvis-qvac/RELEASE.md"
dpkg-deb --build --root-owner-group "${PKG_DIR}" "${OUT_DIR}/jarvis-qvac_${VERSION}_${ARCH}.deb" >/dev/null
dpkg-deb -Zgzip -z6 --build --root-owner-group "${PKG_DIR}" "${OUT_DIR}/jarvis-qvac_${VERSION}_${ARCH}.deb" >/dev/null
rm -rf "${PKG_DIR}"
echo "${OUT_DIR}/jarvis-qvac_${VERSION}_${ARCH}.deb"
+1 -1
View File
@@ -5,7 +5,7 @@ cd "${ROOT_DIR}"
mkdir -p dist
rm -f dist/jarvis-qvac_*.deb dist/jarvis-qvac-extension.zip dist/jarvis-qvac-bare-*.tar.gz dist/SHA256SUMS
bash packaging/build-extension.sh
bash packaging/build-deb.sh
bash packaging/build-runtime-bundle.sh
bash packaging/build-deb.sh
(cd dist && sha256sum jarvis-qvac_*.deb jarvis-qvac-extension.zip jarvis-qvac-bare-*.tar.gz > SHA256SUMS)
echo "Release artifacts written to ${ROOT_DIR}/dist"
+4
View File
@@ -27,6 +27,10 @@ find "${BUNDLE_DIR}/node_modules" -maxdepth 1 -type d -name 'bare-runtime-*' ! -
if [[ -d "${BUNDLE_DIR}/node_modules/@esbuild" ]]; then
find "${BUNDLE_DIR}/node_modules/@esbuild" -mindepth 1 -maxdepth 1 -type d ! -name 'linux-x64' -exec rm -rf {} +
fi
(cd "${BUNDLE_DIR}" && bash packaging/bare-launch.sh packaging/bare-run.js packaging/voice-runtime-smoke.js)
if [[ "${JARVIS_VERIFY_VOICE_MODELS:-0}" == "1" ]]; then
(cd "${BUNDLE_DIR}" && bash packaging/bare-launch.sh packaging/bare-run.js packaging/voice-model-smoke.js)
fi
tar -C "${OUT_DIR}" -czf "${OUT_DIR}/jarvis-qvac-bare-${PLATFORM}.tar.gz" "jarvis-qvac-bare-${PLATFORM}"
rm -rf "${BUNDLE_DIR}"
echo "${OUT_DIR}/jarvis-qvac-bare-${PLATFORM}.tar.gz"
+11
View File
@@ -0,0 +1,11 @@
import { PipeWireCapture } from '../daemon/audio-pipewire.js';
const capture = new PipeWireCapture();
let bytes = 0;
await new Promise((resolve, reject) => {
const timer = setTimeout(() => { capture.stop(); bytes ? resolve() : reject(new Error('No microphone PCM received')); }, 2000);
capture.on('audio', (chunk) => { bytes += chunk.length; });
capture.on('error', (error) => { clearTimeout(timer); capture.stop(); reject(error); });
capture.on('diagnostic', (message) => console.log(message));
capture.start();
});
console.log(`PipeWire microphone delivered ${bytes} PCM bytes; audio was not saved`);
+1
View File
@@ -4,6 +4,7 @@ if [[ "${1:-}" != "--yes" ]]; then
echo "Refusing to uninstall without --yes. Jarvis data is preserved by default." >&2
exit 2
fi
gnome-extensions disable [email protected] 2>/dev/null || true
systemctl --user disable --now jarvisd.service 2>/dev/null || true
rm -f "${HOME}/.config/systemd/user/jarvisd.service"
rm -rf "${HOME}/.local/share/jarvis-qvac" "${HOME}/.local/share/gnome-shell/extensions/[email protected]"
+19
View File
@@ -0,0 +1,19 @@
import { QvacVoiceAdapter } from '../daemon/voice-adapters.js';
import { PipeWirePlayback } from '../daemon/audio-playback.js';
import { closeQvac } from '../daemon/qvac-master.js';
const tts = new QvacVoiceAdapter({ role: 'tts' });
const asr = new QvacVoiceAdapter({ role: 'asr' });
try {
await tts.start();
const audio = await tts.speak('The voice system is ready.');
if (!audio.samples.length || !audio.samples.some((sample) => sample !== 0)) throw new Error('TTS returned empty or silent audio');
if (process.argv.includes('--play')) await new PipeWirePlayback().play(audio.samples);
console.log(`TTS generated ${audio.samples.length} samples at ${audio.sampleRate} Hz`);
await asr.start();
// Convert 44.1 kHz synthesis to the capture contract (16 kHz mono PCM).
const resampled = new Int16Array(Math.floor(audio.samples.length * 16000 / audio.sampleRate));
for (let i = 0; i < resampled.length; i++) resampled[i] = audio.samples[Math.floor(i * audio.sampleRate / 16000)];
const text = await asr.transcribeAudio(Buffer.from(resampled.buffer));
if (!/voice|system|ready/i.test(text)) throw new Error(`ASR roundtrip failed: ${text}`);
console.log(`ASR recognized: ${text}`);
} finally { await asr.stop(); await tts.stop(); await closeQvac(); }
+10
View File
@@ -0,0 +1,10 @@
import { qvacSdk } from '../daemon/qvac-master.js';
const sdk = await qvacSdk();
for (const type of ['llamacpp-completion', 'whispercpp-transcription', 'tts-ggml']) {
if (!sdk.hasPlugin(type)) throw new Error(`Release is missing registered plugin: ${type}`);
}
for (const model of ['WHISPER_TINY', 'VAD_SILERO_5_1_2', 'TTS_EN_SUPERTONIC_Q8_0']) {
if (!sdk[model]) throw new Error(`Release is missing model asset: ${model}`);
}
console.log('Voice runtime: native ASR/TTS plugins and model registry verified');
await sdk.close();
+4 -1
View File
@@ -16,6 +16,8 @@ function harness() {
}
add_child(child) { this.children.push(child); }
destroy_all_children() { this.children = []; }
get_n_children() { return this.children.length; }
contains() { return false; }
get_last_child() { return this.children[this.children.length - 1] || null; }
connect() { return 1; }
hide() { this.visible = false; }
@@ -28,9 +30,10 @@ function harness() {
}
const context = vm.createContext({
Extension: class {},
global: { stage: { get_key_focus() { return null; } } },
St: { BoxLayout: Actor, Label: Actor, Widget: Actor, Button: Actor, Entry: Actor, ScrollView: Actor, PolicyType: { NEVER: 0, AUTOMATIC: 1 } },
Clutter: { ActorAlign: { CENTER: 0, START: 1 }, EVENT_STOP: 1, EVENT_PROPAGATE: 0 },
Pango: { EllipsizeMode: { NONE: 0, END: 3 } },
Pango: { WrapMode: { WORD_CHAR: 2 }, EllipsizeMode: { NONE: 0, END: 3 } },
Main: { layoutManager: { addChrome() {} } },
GLib: {
PRIORITY_DEFAULT: 0, SOURCE_REMOVE: false,
+32 -1
View File
@@ -51,7 +51,7 @@ test('Phase 4 feedback gate drops capture while speaking and during cooldown', (
test('Phase 4 PipeWire capture uses a named 16 kHz mono node', () => {
let args; const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.kill = () => {};
const capture = new PipeWireCapture({ spawnImpl: (_cmd, received) => { args = received; return child; } }); capture.start();
assert.deepEqual(args, ['--record', '--raw', '--format', 's16', '--rate', '16000', '--channels', '1', '--name', 'Jarvis']);
assert.deepEqual(args, ['--record', '--raw', '--format', 's16', '--rate', '16000', '--channels', '1', '--properties', 'node.name=Jarvis', '-']);
capture.stop();
});
@@ -80,3 +80,34 @@ test('PCM packing uses even s16le sample pairs', () => {
assert.equal(fromBytes.samples.length, 2);
assert.equal(fromBytes.samples[0], 256);
});
test('ASR failure preserves speech output and reports unavailable microphone', async () => {
const capture = new EventEmitter(); let captured = false;
capture.start = () => { captured = true; }; capture.stop = () => {};
let spoken = '';
const loop = new VoiceLoop({ capture, wake: new WakeEngine(),
asr: { start: async () => { throw new Error('Whisper plugin missing'); } },
tts: { start: async () => {}, speak: async (text) => { spoken = text; return { samples: new Int16Array(8) }; } },
playback: { play: async () => {}, stop() {} },
});
loop.on('error', () => {});
await loop.start(); await loop.speak('Speech still works.');
assert.equal(spoken, 'Speech still works.'); assert.equal(captured, false);
assert.equal(loop.status.tts, true); assert.equal(loop.status.asr, false);
assert.match(loop.status.errors.asr, /Whisper/); await loop.stop();
});
test('TTS failure preserves ASR and push to talk without wake command', async () => {
const capture = new EventEmitter(); capture.start = () => {}; capture.stop = () => {};
const loop = new VoiceLoop({ capture, wake: new WakeEngine(),
asr: { start: async () => {} }, tts: { start: async () => { throw new Error('TTS failed'); } },
playback: { stop() {} },
});
loop.on('error', () => {}); await loop.start(); loop.setPushToTalk(true);
assert.equal(loop.status.asr, true); assert.equal(loop.ptt, true);
assert.equal(loop.status.tts, false); assert.equal(loop.status.wake, false); await loop.stop();
});
test('QVAC numeric PCM arrays preserve signed 16-bit samples', () => {
assert.deepEqual([...pcmS16le([256, -32768, 32767]).samples], [256, -32768, 32767]);
});
+9 -4
View File
@@ -79,11 +79,16 @@ async function ensureInit() {
// the in-process inference surface and register the engines they own.
mod = await import('@qvac/inference');
const { llmPlugin } = await import('@qvac/inference/llamacpp-completion/plugin');
const { whisperPlugin } = await import('@qvac/inference/whispercpp-transcription/plugin');
const { ttsPlugin } = await import('@qvac/inference/tts-ggml/plugin');
mod.registerPlugin(llmPlugin);
mod.registerPlugin(whisperPlugin);
mod.registerPlugin(ttsPlugin);
// Voice engines fail independently: an unavailable ASR addon must not
// prevent speech output or the text assistant from initializing.
for (const [modulePath, exportName] of [
['@qvac/inference/whispercpp-transcription/plugin', 'whisperPlugin'],
['@qvac/inference/tts-ggml/plugin', 'ttsPlugin'],
]) {
try { const plugin = await import(modulePath); mod.registerPlugin(plugin[exportName]); }
catch (error) { log(`${exportName} unavailable: ${flattenError(error)}`); }
}
} else {
mod = await import('@qvac/sdk');
}