@@ -2,75 +2,137 @@ import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
|
|||||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||||
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
||||||
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
||||||
import * as Shell from 'gi://Shell';
|
import Shell from 'gi://Shell';
|
||||||
import * as Meta from 'gi://Meta';
|
import Meta from 'gi://Meta';
|
||||||
import Gio from 'gi://Gio';
|
import Gio from 'gi://Gio';
|
||||||
import GLib from 'gi://GLib';
|
import GLib from 'gi://GLib';
|
||||||
import St from 'gi://St';
|
import St from 'gi://St';
|
||||||
import Clutter from 'gi://Clutter';
|
import Clutter from 'gi://Clutter';
|
||||||
|
import Pango from 'gi://Pango';
|
||||||
import { installShellService } from './shell-dbus.js';
|
import { installShellService } from './shell-dbus.js';
|
||||||
|
|
||||||
const BUS = 'io.qvac.Jarvis';
|
const BUS = 'io.qvac.Jarvis';
|
||||||
const PATH = '/io/qvac/Jarvis';
|
const PATH = '/io/qvac/Jarvis';
|
||||||
const IFACE = 'io.qvac.Jarvis.Session';
|
const IFACE = 'io.qvac.Jarvis.Session';
|
||||||
|
const OVERLAY_WIDTH = 720;
|
||||||
const STATES = new Set(['ARMED', 'LISTENING', 'THINKING', 'SPEAKING', 'SLEEPING']);
|
const STATES = new Set(['ARMED', 'LISTENING', 'THINKING', 'SPEAKING', 'SLEEPING']);
|
||||||
const safeText = (value) => String(value ?? '').replace(/[<>]/g, '');
|
const coerceText = (value) => {
|
||||||
|
if (value == null) return '';
|
||||||
|
if (typeof value === 'string') return value === '[object Object]' ? '' : value;
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (typeof value.text === 'string') return value.text;
|
||||||
|
if (typeof value.message === 'string') return value.message;
|
||||||
|
if (typeof value.deep_unpack === 'function') return coerceText(value.deep_unpack());
|
||||||
|
}
|
||||||
|
const fallback = String(value);
|
||||||
|
return fallback === '[object Object]' ? '' : fallback;
|
||||||
|
};
|
||||||
|
const safeText = (value) => coerceText(value).replace(/[<>]/g, '');
|
||||||
|
|
||||||
class JarvisProxy {
|
class JarvisProxy {
|
||||||
async connect() {
|
async connect() {
|
||||||
this.proxy = await new Promise((resolve, reject) => Gio.DBusProxy.new(
|
const proxy = await new Promise((resolve, reject) => Gio.DBusProxy.new(
|
||||||
Gio.DBus.session, Gio.DBusProxyFlags.DO_NOT_AUTO_START, null, BUS, PATH, IFACE, null,
|
Gio.DBus.session, Gio.DBusProxyFlags.DO_NOT_AUTO_START, null, BUS, PATH, IFACE, null,
|
||||||
(source, result) => { try { resolve(Gio.DBusProxy.new_finish(result)); } catch (error) { reject(error); } },
|
(source, result) => { try { resolve(Gio.DBusProxy.new_finish(result)); } catch (error) { reject(error); } },
|
||||||
));
|
));
|
||||||
|
if (this._closed) { proxy.run_dispose(); return this; }
|
||||||
|
this.proxy = proxy;
|
||||||
|
this._ownerSignal = proxy.connect('notify::g-name-owner', () => this.onOwnerChanged?.(this.owned()));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
owned() { try { return Boolean(this.proxy?.g_name_owner || this.proxy?.get_name_owner?.()); } catch { return false; } }
|
||||||
call(name, signature = null, value = null) {
|
call(name, signature = null, value = null) {
|
||||||
if (!this.proxy) return Promise.reject(new Error('Jarvis daemon is unavailable'));
|
if (!this.proxy) return Promise.reject(new Error('Jarvis daemon is unavailable'));
|
||||||
return new Promise((resolve, reject) => this.proxy.call(name, signature ? new GLib.Variant(signature, value) : null, Gio.DBusCallFlags.NONE, -1, null,
|
return new Promise((resolve, reject) => this.proxy.call(name, signature ? new GLib.Variant(signature, value) : null, Gio.DBusCallFlags.NONE, -1, null,
|
||||||
(source, result) => { try { resolve(source.call_finish(result)); } catch (error) { reject(error); } }));
|
(source, result) => { try { resolve(source.call_finish(result)); } catch (error) { reject(error); } }));
|
||||||
}
|
}
|
||||||
on(name, handler) { return this.proxy?.connect('g-signal', (_p, _sender, signal, params) => { if (signal === name) handler(...params.deep_unpack()); }); }
|
on(name, handler) { return this.proxy?.connect('g-signal', (_p, _sender, signal, params) => { if (signal === name) handler(...params.deep_unpack()); }); }
|
||||||
close() { this.proxy?.run_dispose(); this.proxy = null; }
|
close() {
|
||||||
|
this._closed = true; this.onOwnerChanged = null;
|
||||||
|
if (this._ownerSignal && this.proxy) { try { this.proxy.disconnect(this._ownerSignal); } catch {} }
|
||||||
|
this.proxy?.run_dispose(); this.proxy = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ArcOverlay {
|
class ArcOverlay {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.root = new St.BoxLayout({ style_class: 'jarvis-arc', vertical: true, reactive: true, can_focus: true, track_hover: true });
|
this.root = new St.BoxLayout({ style_class: 'jarvis-arc', vertical: true, reactive: true, can_focus: true, track_hover: true, x_expand: true });
|
||||||
this.root.accessible_name = 'Jarvis ARC voice assistant';
|
this.root.accessible_name = 'Jarvis ARC voice assistant';
|
||||||
this.header = new St.BoxLayout({ style_class: 'jarvis-arc-header' });
|
this.header = new St.BoxLayout({ style_class: 'jarvis-arc-header', x_expand: true });
|
||||||
this.title = new St.Label({ text: '◉ JARVIS', style_class: 'jarvis-title' }); this.title.accessible_name = 'Jarvis status';
|
this.title = new St.Label({ text: '◉ JARVIS', style_class: 'jarvis-title', x_align: Clutter.ActorAlign.START }); this.title.accessible_name = 'Jarvis status';
|
||||||
|
if (this.title.clutter_text) this.title.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
|
||||||
this.status = new St.Label({ text: 'LOCAL', style_class: 'jarvis-local' }); this.status.accessible_name = 'Local model status';
|
this.status = new St.Label({ text: 'LOCAL', style_class: 'jarvis-local' }); this.status.accessible_name = 'Local model status';
|
||||||
this.header.add_child(this.title); this.header.add_child(new St.Widget({ hexpand: true })); this.header.add_child(this.status);
|
if (this.status.clutter_text) this.status.clutter_text.ellipsize = Pango.EllipsizeMode.END;
|
||||||
this.statusLine = new St.Label({ text: 'armed', style_class: 'jarvis-status-line' });
|
this.header.add_child(this.title); this.header.add_child(new St.Widget({ x_expand: true })); this.header.add_child(this.status);
|
||||||
this.job = new St.Label({ text: '', style_class: 'jarvis-job', can_focus: true }); this.job.accessible_name = 'Jarvis job progress';
|
this.statusLine = new St.Label({ text: 'Super+Shift+J · Hold Talk to speak', style_class: 'jarvis-status-line' });
|
||||||
this.target = new St.Label({ text: '', style_class: 'jarvis-target', can_focus: true }); this.target.accessible_name = 'Computer use target';
|
this.job = new St.Label({ text: '', style_class: 'jarvis-job', can_focus: true, visible: false }); this.job.accessible_name = 'Jarvis job progress';
|
||||||
this.cursor = new St.Label({ text: '', style_class: 'jarvis-agent-cursor', can_focus: false }); this.cursor.accessible_name = 'Visible computer use cursor';
|
this.target = new St.Label({ text: '', style_class: 'jarvis-target', can_focus: true, visible: false }); this.target.accessible_name = 'Computer use target';
|
||||||
this.transcript = new St.BoxLayout({ style_class: 'jarvis-transcript', vertical: true }); this.transcript.accessible_name = 'Conversation transcript';
|
this.cursor = new St.Label({ text: '', style_class: 'jarvis-agent-cursor', can_focus: false, visible: false }); this.cursor.accessible_name = 'Visible computer use cursor';
|
||||||
this.wave = new St.BoxLayout({ style_class: 'jarvis-wave' }); this.bars = [];
|
this.scroll = new St.ScrollView({ style_class: 'jarvis-transcript-scroll', overlay_scrollbars: true, x_expand: true, y_expand: true });
|
||||||
|
try { this.scroll.hscrollbar_policy = St.PolicyType.NEVER; this.scroll.vscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {}
|
||||||
|
this.transcript = new St.BoxLayout({ style_class: 'jarvis-transcript', vertical: true, x_expand: true }); this.transcript.accessible_name = 'Conversation transcript';
|
||||||
|
if (typeof this.scroll.set_child === 'function') this.scroll.set_child(this.transcript); else this.scroll.add_child(this.transcript);
|
||||||
|
this.wave = new St.BoxLayout({ style_class: 'jarvis-wave', visible: false }); this.bars = [];
|
||||||
for (let i = 0; i < 48; i++) { const bar = new St.Widget({ style_class: 'jarvis-wave-bar', height: 4, y_align: Clutter.ActorAlign.CENTER }); this.wave.add_child(bar); this.bars.push(bar); }
|
for (let i = 0; i < 48; i++) { const bar = new St.Widget({ style_class: 'jarvis-wave-bar', height: 4, y_align: Clutter.ActorAlign.CENTER }); this.wave.add_child(bar); this.bars.push(bar); }
|
||||||
this.chips = new St.BoxLayout({ style_class: 'jarvis-chips' }); this.chips.accessible_name = 'Suggested actions';
|
this.chips = new St.BoxLayout({ style_class: 'jarvis-chips' }); this.chips.accessible_name = 'Suggested actions';
|
||||||
for (const mode of ['Chat', 'Files', 'Vision', 'Imagine', 'Compose', 'Translate', 'Dictate', 'Computer', 'Lab']) {
|
for (const mode of ['Chat', 'Files', 'Vision', 'Imagine', 'Compose', 'Translate', 'Dictate', 'Computer', 'Lab']) {
|
||||||
const chip = new St.Button({ label: mode, style_class: 'jarvis-chip', can_focus: true, reactive: true }); chip.accessible_name = `${mode} mode`;
|
const chip = new St.Button({ label: mode, style_class: 'jarvis-chip', can_focus: true, reactive: true }); chip.accessible_name = `${mode} mode`;
|
||||||
chip.connect('clicked', () => this.onMode?.(mode.toLowerCase())); this.chips.add_child(chip);
|
chip.connect('clicked', () => this.onMode?.(mode.toLowerCase())); this.chips.add_child(chip);
|
||||||
}
|
}
|
||||||
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.transcript); this.root.add_child(this.chips);
|
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.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);
|
||||||
|
for (const [label, action] of [['Stop', () => this.onStop?.()], ['Close', () => this.hide()]]) {
|
||||||
|
const button = new St.Button({ label, style_class: 'jarvis-chip', reactive: true, can_focus: true });
|
||||||
|
button.connect('clicked', action); this.controls.add_child(button);
|
||||||
|
}
|
||||||
|
this.entry = new St.Entry({ hint_text: 'Ask Jarvis…', can_focus: true, x_expand: true });
|
||||||
|
this.entry.clutter_text.connect('activate', () => { const text = this.entry.get_text().trim(); if (text) { this.onAsk?.(text); this.entry.set_text(''); } });
|
||||||
|
this.root.add_child(this.entry); this.root.add_child(this.controls);
|
||||||
this.reducedMotion = false;
|
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(); }
|
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() { this.root.visible = true; this.root.grab_key_focus(); }
|
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; }
|
hide() { this.root.visible = false; }
|
||||||
toggle() { this.root.visible ? this.hide() : this.show(); }
|
toggle() { this.root.visible ? this.hide() : this.show(); }
|
||||||
clear() { this.transcript.destroy_all_children(); }
|
clear() { this.transcript.destroy_all_children(); }
|
||||||
addRow(who, text) { const row = new St.Label({ text: `${who === 'J' ? 'J' : 'YOU'} ${safeText(text)}`, style_class: `jarvis-row jarvis-row-${who === 'J' ? 'jarvis' : 'user'}`, can_focus: true }); row.accessible_name = `${who === 'J' ? 'Jarvis' : 'You'}: ${safeText(text)}`; this.transcript.add_child(row); this.show(); }
|
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; }
|
||||||
token(text) { let row = this.transcript.get_last_child?.(); if (!row || !row.style_class?.includes('jarvis-row-jarvis')) { this.addRow('J', ''); row = this.transcript.get_last_child(); } row.text = `${row.text}${safeText(text)}`; row.accessible_name = `Jarvis: ${row.text}`; this.show(); }
|
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(); }
|
||||||
setState(state) { const value = STATES.has(state) ? state : 'ARMED'; this.statusLine.text = value.toLowerCase(); this.title.text = `${value === 'SLEEPING' ? '⧸' : '◉'} JARVIS`; this.status.style_class = `jarvis-local jarvis-state-${value.toLowerCase()}`; if (value === 'LISTENING') this.showHalo(); if (value !== 'ARMED') this.show(); }
|
finalizeReply(text) {
|
||||||
level(rms) { const value = Math.max(0, Math.min(1, Number(rms) || 0)); for (let i = 0; i < this.bars.length; i++) this.bars[i].height = Math.max(4, Math.round(4 + value * 32 * (0.45 + Math.abs(Math.sin(i * 1.7)) * 0.55))); }
|
const spoken = safeText(text);
|
||||||
|
let row = this.transcript.get_last_child?.();
|
||||||
|
if (row && String(row.style_class || '').includes('jarvis-row-jarvis')) {
|
||||||
|
const body = String(row.text || '').replace(/^J\s+/, '');
|
||||||
|
if (spoken && !body.trim()) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; }
|
||||||
|
this.show(); return;
|
||||||
|
}
|
||||||
|
if (spoken) this.addRow('J', spoken);
|
||||||
|
}
|
||||||
|
setConnectionStatus(kind) {
|
||||||
|
const labels = { local: 'LOCAL', offline: 'LOCAL · daemon unavailable', 'voice-unavailable': 'LOCAL · voice unavailable' };
|
||||||
|
this.status.text = labels[kind] || labels.local;
|
||||||
|
this.status.accessible_name = this.status.text;
|
||||||
|
}
|
||||||
|
setState(state) {
|
||||||
|
const value = STATES.has(state) ? state : 'ARMED';
|
||||||
|
this.statusLine.text = `${value.toLowerCase()} · Super+Shift+J · Hold Talk to speak`;
|
||||||
|
this.title.text = `${value === 'SLEEPING' ? '⧸' : '◉'} JARVIS`;
|
||||||
|
this.status.style_class = `jarvis-local jarvis-state-${value.toLowerCase()}`;
|
||||||
|
this.wave.visible = value === 'LISTENING' || value === 'SPEAKING';
|
||||||
|
if (value === 'LISTENING') this.showHalo(); if (value !== 'ARMED') this.show();
|
||||||
|
}
|
||||||
|
level(rms) { this.wave.visible = true; const value = Math.max(0, Math.min(1, Number(rms) || 0)); for (let i = 0; i < this.bars.length; i++) this.bars[i].height = Math.max(4, Math.round(4 + value * 32 * (0.45 + Math.abs(Math.sin(i * 1.7)) * 0.55))); }
|
||||||
addChip(id, label, payload) { const chip = new St.Button({ label: safeText(label), style_class: 'jarvis-chip jarvis-chip-suggested', can_focus: true }); chip.accessible_name = `Suggested action: ${safeText(label)}`; chip.connect('clicked', () => this.onSuggestion?.(id, payload)); this.chips.add_child(chip); }
|
addChip(id, label, payload) { const chip = new St.Button({ label: safeText(label), style_class: 'jarvis-chip jarvis-chip-suggested', can_focus: true }); chip.accessible_name = `Suggested action: ${safeText(label)}`; chip.connect('clicked', () => this.onSuggestion?.(id, payload)); this.chips.add_child(chip); }
|
||||||
addStep(json) { try { const step = JSON.parse(json); this.addRow('J', `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`); } catch { this.addRow('J', json); } }
|
addStep(json) { try { const step = JSON.parse(json); this.addRow('J', `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`); } catch { this.addRow('J', json); } }
|
||||||
addJob(id, pct, label) { this.job.text = `${safeText(label)} · ${Math.round(Number(pct) * 100)}%`; this.job.visible = true; this.show(); }
|
addJob(id, pct, label) { this.job.text = `${safeText(label)} · ${Math.round(Number(pct) * 100)}%`; this.job.visible = true; this.show(); }
|
||||||
setTarget(json) { this.target.text = `⌾ ${safeText(json)}`; this.cursor.text = '◎'; this.cursor.visible = true; this.target.visible = true; this.show(); }
|
setTarget(json) { this.target.text = `⌾ ${safeText(json)}`; this.cursor.text = '◎'; this.cursor.visible = true; this.target.visible = true; this.show(); }
|
||||||
showHalo() { if (!this.halo) return; this.halo.show(); GLib.timeout_add(GLib.PRIORITY_DEFAULT, 400, () => { this.halo?.hide(); return GLib.SOURCE_REMOVE; }); }
|
showHalo() { if (!this.halo) return; this.halo.show(); if (this._haloTimeout) GLib.Source.remove(this._haloTimeout); this._haloTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 400, () => { this._haloTimeout = 0; this.halo?.hide(); return GLib.SOURCE_REMOVE; }); }
|
||||||
destroy() { this.halo?.destroy(); this.root.destroy(); }
|
destroy() { if (this._haloTimeout) GLib.Source.remove(this._haloTimeout); this._haloTimeout = 0; this.halo?.destroy(); this.halo = null; this.root.destroy(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class JarvisExtension extends Extension {
|
export default class JarvisExtension extends Extension {
|
||||||
@@ -78,24 +140,60 @@ export default class JarvisExtension extends Extension {
|
|||||||
this._theme = St.ThemeContext.get_for_stage(global.stage).get_theme(); this._stylesheet = Gio.File.new_for_path(`${this.path}/stylesheet.css`); try { this._theme.load_stylesheet(this._stylesheet); } catch (error) { log(`Jarvis stylesheet unavailable: ${error.message}`); }
|
this._theme = St.ThemeContext.get_for_stage(global.stage).get_theme(); this._stylesheet = Gio.File.new_for_path(`${this.path}/stylesheet.css`); try { this._theme.load_stylesheet(this._stylesheet); } catch (error) { log(`Jarvis stylesheet unavailable: ${error.message}`); }
|
||||||
this.settings = this.getSettings(); this.proxy = new JarvisProxy(); this.overlay = new ArcOverlay(); this.overlay.attach(); this._applyAccessibility();
|
this.settings = this.getSettings(); this.proxy = new JarvisProxy(); this.overlay = new ArcOverlay(); this.overlay.attach(); this._applyAccessibility();
|
||||||
this._removeShellService = installShellService();
|
this._removeShellService = installShellService();
|
||||||
this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', true); this._indicator.accessible_name = 'Jarvis voice assistant';
|
this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', false); this._indicator.accessible_name = 'Jarvis voice assistant';
|
||||||
this._glyph = new St.Label({ text: '◯', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle'; this._indicator.add_child(this._glyph);
|
this._glyph = new St.Label({ text: '◯ Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle'; this._indicator.add_child(this._glyph);
|
||||||
Main.panel.addToStatusArea('jarvis-qvac', this._indicator, 0, 'right');
|
Main.panel.addToStatusArea('jarvis-qvac', this._indicator, 0, 'right');
|
||||||
this._indicator.connect('button-press-event', (_actor, event) => { const button = event.get_button(); if (button === 2) { this._call('PushToTalk', '(b)', [true]); return Clutter.EVENT_STOP; } if (button === 3) { this._buildMenu(); return Clutter.EVENT_STOP; } this.overlay.toggle(); return Clutter.EVENT_STOP; });
|
this._indicator.connect('button-press-event', (_actor, event) => { const button = event.get_button(); if (button === 2) { this._call('Arm'); return Clutter.EVENT_STOP; } if (button === 3) { this._buildMenu(); return Clutter.EVENT_STOP; } this.overlay.toggle(); return Clutter.EVENT_STOP; });
|
||||||
|
this.overlay.onTalk = (pressed) => { if (pressed) { this._call('PushToTalk', '(b)', [true]); this._call('Arm'); } else { this._call('PushToTalk', '(b)', [false]); } };
|
||||||
|
this.overlay.onStop = () => this._call('Cancel'); this.overlay.onAsk = (text) => { this.overlay.addRow('U', text); this._call('Ask', '(s)', [text]); };
|
||||||
this.overlay.onMode = (mode) => this._call('SetMode', '(s)', [mode]); this.overlay.onSuggestion = (id, payload) => this._call('Ask', '(s)', [payload?.text || payload?.prompt || id]);
|
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.ALL, () => this.overlay.toggle()); } catch (error) { log(`Jarvis hotkey unavailable: ${error.message}`); }
|
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._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._call('ComputerRevoke'); }); } catch {}
|
||||||
}
|
}
|
||||||
async _connectDaemon() {
|
async _connectDaemon() {
|
||||||
try { await this.proxy.connect(); this._signals = [
|
const proxy = this.proxy;
|
||||||
['StateChanged', (state) => this._setState(state)], ['WakeHeard', () => this.overlay.show()], ['PartialTranscript', (text) => this.overlay.addRow('U', text)], ['Token', (text) => this.overlay.token(text)], ['Reply', (text) => { this.overlay.addRow('J', text); this.overlay.show(); }], ['SpeakingLevel', (rms) => this.overlay.level(rms)], ['ListeningLevel', (rms) => this.overlay.level(rms)], ['ChipOffered', (id, label, payload) => this.overlay.addChip(id, label, payload)], ['JobProgress', (id, pct, label) => this.overlay.addJob(id, pct, label)], ['ComputerStep', (json) => this.overlay.addStep(json)], ['ComputerHighlight', (json) => this.overlay.setTarget(json)], ['Error', (_code, message) => this.overlay.addRow('J', message)],
|
try {
|
||||||
].map(([name, handler]) => this.proxy.on(name, handler)); const result = await this.proxy.call('GetState'); this._setState(result.deep_unpack()[0]); } catch (error) { this.overlay.status.text = 'LOCAL · daemon unavailable'; log(`Jarvis daemon unavailable: ${error.message}`); }
|
await proxy.connect(); if (this.proxy !== proxy) return;
|
||||||
|
this._signals = [
|
||||||
|
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
|
||||||
|
['WakeHeard', () => this.overlay.show()],
|
||||||
|
['PartialTranscript', (text) => this.overlay.addRow('U', text)],
|
||||||
|
['Token', (text) => this.overlay.token(text)],
|
||||||
|
['Reply', (text) => { this.overlay.finalizeReply(text); this.overlay.show(); }],
|
||||||
|
['SpeakingLevel', (rms) => this.overlay.level(rms)],
|
||||||
|
['ListeningLevel', (rms) => this.overlay.level(rms)],
|
||||||
|
['ChipOffered', (id, label, payload) => this.overlay.addChip(id, label, payload)],
|
||||||
|
['JobProgress', (id, pct, label) => this.overlay.addJob(id, pct, label)],
|
||||||
|
['ComputerStep', (json) => this.overlay.addStep(json)],
|
||||||
|
['ComputerHighlight', (json) => this.overlay.setTarget(json)],
|
||||||
|
['Error', (code, message) => { this.overlay.addRow('J', message); if (coerceText(code) === 'VOICE_UNAVAILABLE') this.overlay.setConnectionStatus('voice-unavailable'); }],
|
||||||
|
].map(([name, handler]) => proxy.on(name, handler));
|
||||||
|
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
|
||||||
|
await this._syncDaemon();
|
||||||
|
} catch (error) { if (this.proxy !== proxy) return; this.overlay?.setConnectionStatus('offline'); log(`Jarvis daemon unavailable: ${error.message}`); }
|
||||||
}
|
}
|
||||||
_call(name, signature, value) { this.proxy.call(name, signature, value).catch((error) => log(`Jarvis ${name}: ${error.message}`)); }
|
async _syncDaemon() {
|
||||||
_setState(state) { const value = safeText(state); this._glyph.text = ({ ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' })[value] || '◯'; this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`; this.overlay.setState(value); }
|
if (!this.proxy) return;
|
||||||
|
if (!this.proxy.owned()) { this.overlay?.setConnectionStatus('offline'); return; }
|
||||||
|
try {
|
||||||
|
const result = await this.proxy.call('GetState'); if (!this.overlay) return;
|
||||||
|
this._setState(result.deep_unpack()[0]);
|
||||||
|
await this._refreshVoiceStatus();
|
||||||
|
} catch (error) { this.overlay?.setConnectionStatus('offline'); log(`Jarvis daemon unavailable: ${error.message}`); }
|
||||||
|
}
|
||||||
|
async _refreshVoiceStatus() {
|
||||||
|
if (!this.proxy?.owned?.() || !this.overlay) return;
|
||||||
|
try {
|
||||||
|
const runtime = await this.proxy.call('GetRuntimeStatus');
|
||||||
|
const status = JSON.parse(runtime.deep_unpack()[0] || '{}');
|
||||||
|
this.overlay.setConnectionStatus(status.voice ? 'local' : 'voice-unavailable');
|
||||||
|
} catch { this.overlay.setConnectionStatus('local'); }
|
||||||
|
}
|
||||||
|
_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); }
|
||||||
_applyAccent() { this.overlay.root.set_style(`--jarvis-accent: ${this.settings.get_string('accent-color')};`); }
|
_applyAccent() { this.overlay.root.set_style(`--jarvis-accent: ${this.settings.get_string('accent-color')};`); }
|
||||||
_applyAccessibility() { try { const desktop = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); this.overlay.reducedMotion = desktop.list_keys().includes('enable-animations') && !desktop.get_boolean('enable-animations'); const theme = desktop.list_keys().includes('gtk-theme') ? desktop.get_string('gtk-theme') : ''; if (/high.?contrast/i.test(theme)) this.overlay.root.add_style_class_name('jarvis-high-contrast'); } catch {} this.overlay.root.connect('key-press-event', (_actor, event) => { if (event.get_key_symbol() === Clutter.KEY_Escape) { this._call('Cancel'); this.overlay.hide(); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; }); }
|
_applyAccessibility() { try { const desktop = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); this.overlay.reducedMotion = desktop.list_keys().includes('enable-animations') && !desktop.get_boolean('enable-animations'); const theme = desktop.list_keys().includes('gtk-theme') ? desktop.get_string('gtk-theme') : ''; if (/high.?contrast/i.test(theme)) this.overlay.root.add_style_class_name('jarvis-high-contrast'); } catch {} this.overlay.root.connect('key-press-event', (_actor, event) => { if (event.get_key_symbol() === Clutter.KEY_Escape) { this._call('Cancel'); this.overlay.hide(); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; }); }
|
||||||
_buildMenu() { const menu = this._indicator.menu; menu.removeAll(); for (const [label, action] of [['Open ARC', () => this.overlay.show()], ['Stop', () => this._call('Cancel')], ['Privacy mode', () => this._call('Sleep')], ['Settings', () => this.openPreferences()]]) { const item = new PopupMenu.PopupMenuItem(label); item.connect('activate', action); menu.addMenuItem(item); } menu.open(); }
|
_buildMenu() { const menu = this._indicator.menu; menu.removeAll(); for (const [label, action] of [['Open ARC', () => this.overlay.show()], ['Talk', () => this._call('Arm')], ['Stop', () => this._call('Cancel')], ['Privacy mode', () => this._call('Sleep')], ['Settings', () => this.openPreferences()]]) { const item = new PopupMenu.PopupMenuItem(label); item.connect('activate', action); menu.addMenuItem(item); } menu.open(); }
|
||||||
disable() { this._removeShellService?.(); try { Main.screenShield.disconnect(this._lockChanged); } catch {} try { Main.wm.removeKeybinding(this._keyName); } catch {} if (this._settingsChanged) this.settings.disconnect(this._settingsChanged); this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id)); this.proxy?.close(); this.overlay?.destroy(); this._indicator?.destroy(); if (this._theme && this._stylesheet) { try { this._theme.unload_stylesheet(this._stylesheet); } catch {} } this.overlay = this._indicator = this._glyph = this.proxy = null; }
|
disable() { this._removeShellService?.(); try { Main.screenShield.disconnect(this._lockChanged); } catch {} try { Main.wm.removeKeybinding(this._keyName); } catch {} if (this._settingsChanged) this.settings.disconnect(this._settingsChanged); this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id)); this.proxy?.close(); this.overlay?.destroy(); this._indicator?.destroy(); if (this._theme && this._stylesheet) { try { this._theme.unload_stylesheet(this._stylesheet); } catch {} } this.overlay = this._indicator = this._glyph = this.proxy = null; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||||
|
import Adw from 'gi://Adw';
|
||||||
|
|
||||||
export default class JarvisPreferences extends ExtensionPreferences {
|
export default class JarvisPreferences extends ExtensionPreferences {
|
||||||
fillPreferencesWindow(window) {
|
fillPreferencesWindow(window) {
|
||||||
window.set_title('Jarvis QVAC');
|
window.set_title('Jarvis QVAC');
|
||||||
|
const page = new Adw.PreferencesPage();
|
||||||
|
const group = new Adw.PreferencesGroup({ title: 'Using Jarvis' });
|
||||||
|
const settings = this.getSettings();
|
||||||
|
group.add(new Adw.ActionRow({ title: 'Open the assistant', subtitle: settings.get_strv('hotkey').join(', ') }));
|
||||||
|
group.add(new Adw.ActionRow({ title: 'Voice and text', subtitle: 'Click Jarvis in the top panel, then hold Talk to speak, or type a question and press Enter.' }));
|
||||||
|
group.add(new Adw.ActionRow({ title: 'Wake phrase setup', subtitle: 'Hey Jarvis requires a local wake detector configured with JARVIS_WAKE_COMMAND. Use Talk when no detector is installed.' }));
|
||||||
|
page.add(group); window.add(page);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<schema id="org.gnome.shell.extensions.jarvis" path="/org/gnome/shell/extensions/jarvis/">
|
<schema id="org.gnome.shell.extensions.jarvis" path="/org/gnome/shell/extensions/jarvis/">
|
||||||
<key name="wake-phrase" type="s"><default>'hey jarvis'</default></key>
|
<key name="wake-phrase" type="s"><default>'hey jarvis'</default></key>
|
||||||
<key name="aliases" type="as"><default>['jarvis', 'okay jarvis']</default></key>
|
<key name="aliases" type="as"><default>['jarvis', 'okay jarvis']</default></key>
|
||||||
<key name="hotkey" type="s"><default>'<Super><Space>'</default></key>
|
<key name="hotkey" type="as"><default>['<Super><Shift>j']</default></key>
|
||||||
<key name="voice-id" type="s"><default>''</default></key>
|
<key name="voice-id" type="s"><default>''</default></key>
|
||||||
<key name="language" type="s"><default>'en-US'</default></key>
|
<key name="language" type="s"><default>'en-US'</default></key>
|
||||||
<key name="privacy-mode" type="s"><default>'full-listen-after-wake'</default></key>
|
<key name="privacy-mode" type="s"><default>'full-listen-after-wake'</default></key>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.jarvis-panel-glyph { color: #F4B942; font-size: 16px; }
|
.jarvis-panel-glyph { color: #F4B942; font-size: 16px; }
|
||||||
.jarvis-arc { width: 720px; padding: 18px; margin-top: 12vh; 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: 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-header { spacing: 8px; }
|
.jarvis-arc-header { spacing: 8px; }
|
||||||
.jarvis-title { font-weight: bold; letter-spacing: 1px; }
|
.jarvis-title { font-weight: bold; letter-spacing: 1px; }
|
||||||
.jarvis-local { color: var(--jarvis-accent, #F4B942); font-size: 11px; }
|
.jarvis-local { color: var(--jarvis-accent, #F4B942); font-size: 11px; }
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
.jarvis-high-contrast { border-width: 2px; background-color: #000; }
|
.jarvis-high-contrast { border-width: 2px; background-color: #000; }
|
||||||
.jarvis-wave { height: 42px; spacing: 3px; }
|
.jarvis-wave { height: 42px; spacing: 3px; }
|
||||||
.jarvis-wave-bar { width: 7px; background-color: var(--jarvis-accent, #F4B942); border-radius: 4px; }
|
.jarvis-wave-bar { width: 7px; background-color: var(--jarvis-accent, #F4B942); border-radius: 4px; }
|
||||||
.jarvis-transcript { spacing: 7px; max-height: 280px; }
|
.jarvis-transcript-scroll { height: 280px; }
|
||||||
|
.jarvis-transcript { spacing: 7px; }
|
||||||
.jarvis-row { padding: 6px 10px; border-radius: 8px; font-size: 14px; }
|
.jarvis-row { padding: 6px 10px; border-radius: 8px; font-size: 14px; }
|
||||||
.jarvis-row-user { border-left: 2px solid var(--jarvis-accent, #F4B942); }
|
.jarvis-row-user { border-left: 2px solid var(--jarvis-accent, #F4B942); }
|
||||||
.jarvis-row-jarvis { border-right: 2px solid #4FD2FF; }
|
.jarvis-row-jarvis { border-right: 2px solid #4FD2FF; }
|
||||||
|
|||||||
+37
-6
@@ -15,6 +15,7 @@ import { PortalInputBackend } from '../computer-use/portal-input.js';
|
|||||||
import { ComputerActuator } from '../computer-use/actuator.js';
|
import { ComputerActuator } from '../computer-use/actuator.js';
|
||||||
import { RuntimeTelemetry } from './telemetry.js';
|
import { RuntimeTelemetry } from './telemetry.js';
|
||||||
import { StateRecovery } from './recovery.js';
|
import { StateRecovery } from './recovery.js';
|
||||||
|
import { spokenReply } from '../skills/voice-prompt.js';
|
||||||
|
|
||||||
export class JarvisDaemon extends EventEmitter {
|
export class JarvisDaemon extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -45,24 +46,54 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
setState(state) { this.state = state; try { this.recovery.save({ state, mode: this.mode }); } catch (error) { this.emit('Error', 'RECOVERY_WRITE', error.message); } this.emit('StateChanged', state); this.log.record('state', { state }).catch(() => {}); }
|
setState(state) { this.state = state; try { this.recovery.save({ state, mode: this.mode }); } catch (error) { this.emit('Error', 'RECOVERY_WRITE', error.message); } this.emit('StateChanged', state); this.log.record('state', { state }).catch(() => {}); }
|
||||||
async arm() { if (this.locked) return; await resumeQvac().catch(() => {}); this.voice.wake(); this.setState('LISTENING'); }
|
async arm() { if (this.locked) return; await resumeQvac().catch(() => {}); this.voice.wake(); this.setState('LISTENING'); }
|
||||||
async sleep() { this.voice.sleep(); this.setState('SLEEPING'); await suspendQvac().catch((error) => this.emit('Error', 'QVAC_SUSPEND', error.message)); }
|
async sleep() { this.voice.sleep(); this.setState('SLEEPING'); await suspendQvac().catch((error) => this.emit('Error', 'QVAC_SUSPEND', error.message)); }
|
||||||
say(text) { this.lastReply = String(text); this.emit('Reply', this.lastReply); }
|
say(text) {
|
||||||
|
const spoken = spokenReply(text) || spokenReply(this.lastReply) || 'There is nothing to repeat.';
|
||||||
|
this.lastReply = spoken;
|
||||||
|
this.emit('Reply', spoken);
|
||||||
|
this._beginSpeech();
|
||||||
|
this._speakReply(spoken);
|
||||||
|
}
|
||||||
async ask(text) {
|
async ask(text) {
|
||||||
this.voice.typedUtterance(); this.setState('THINKING');
|
this.voiceLoop?.interrupt?.();
|
||||||
try {
|
try {
|
||||||
|
this.voice.typedUtterance(); this.setState('THINKING');
|
||||||
const startedAt = Date.now(); const reply = await this.scheduler.run(() => this.harness.ask(text), { lane: 'voice' }); this.telemetry.record('llm', startedAt, { success: true });
|
const startedAt = Date.now(); const reply = await this.scheduler.run(() => this.harness.ask(text), { lane: 'voice' }); this.telemetry.record('llm', startedAt, { success: true });
|
||||||
this.voice.speak(); this.setState('SPEAKING'); this.lastReply = String(reply || ''); this.emit('Reply', this.lastReply); this.voiceLoop?.speak(this.lastReply).catch((error) => this.emit('Error', 'TTS', error.message)); return reply;
|
const spoken = spokenReply(reply);
|
||||||
|
this._beginSpeech(); this.lastReply = spoken; this.emit('Reply', spoken); this._speakReply(spoken); return spoken;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.telemetry.record('llm', Date.now(), { success: false }); this.emit('Error', 'QVAC', error.message); this.voice.cancel(); this.setState('ARMED'); throw error;
|
this.telemetry.record('llm', Date.now(), { success: false }); this.emit('Error', 'QVAC', error.message); this.voice.cancel(); this.setState('ARMED'); throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cancel() { this.harness.cancel(); this.scheduler.cancelQueued((job) => job.lane === 'voice'); this.computer.revoke(); this.voice.cancel(); this.setState('ARMED'); cancelQvac().catch((error) => this.emit('Error', 'QVAC_CANCEL', error.message)); }
|
_beginSpeech() {
|
||||||
|
try {
|
||||||
|
if (this.voice.state === 'ARMED' || this.voice.state === 'SLEEPING') this.voice.wake();
|
||||||
|
if (this.voice.state !== 'SPEAKING') this.voice.speak();
|
||||||
|
} catch {}
|
||||||
|
this.setState('SPEAKING');
|
||||||
|
}
|
||||||
|
_finishSpeech() {
|
||||||
|
try { this.voice.finishSpeaking(); } catch {}
|
||||||
|
if (this.state === 'SPEAKING') this.setState('LISTENING');
|
||||||
|
}
|
||||||
|
_speakReply(spoken) {
|
||||||
|
const playing = this.voiceLoop?.speak?.(spoken);
|
||||||
|
if (!playing) { this._finishSpeech(); return; }
|
||||||
|
Promise.resolve(playing).catch((error) => {
|
||||||
|
this.emit('Error', 'TTS', error.message);
|
||||||
|
if (this.state === 'SPEAKING') this._finishSpeech();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cancel() { this.voiceLoop?.interrupt?.(); this.harness.cancel(); this.scheduler.cancelQueued((job) => job.lane === 'voice'); this.computer.revoke(); this.voice.cancel(); this.setState('ARMED'); cancelQvac().catch((error) => this.emit('Error', 'QVAC_CANCEL', error.message)); }
|
||||||
computerGrant(persist = false) { const result = this.computer.grant({ persist }); this.emit('ComputerStep', JSON.stringify({ action: 'grant', ...result })); this.input.grant({ persist }).then((backend) => { this.computer.setBackend(backend.backend); this.emit('ComputerStep', JSON.stringify({ action: 'backend', ...backend })); }).catch((error) => this.emit('Error', 'CU_GRANT', error.message)); return result; }
|
computerGrant(persist = false) { const result = this.computer.grant({ persist }); this.emit('ComputerStep', JSON.stringify({ action: 'grant', ...result })); this.input.grant({ persist }).then((backend) => { this.computer.setBackend(backend.backend); this.emit('ComputerStep', JSON.stringify({ action: 'backend', ...backend })); }).catch((error) => this.emit('Error', 'CU_GRANT', error.message)); return result; }
|
||||||
computerRevoke() { this.input.revoke(); this.computer.revoke(); this.emit('ComputerStep', JSON.stringify({ action: 'revoke' })); }
|
computerRevoke() { this.input.revoke(); this.computer.revoke(); this.emit('ComputerStep', JSON.stringify({ action: 'revoke' })); }
|
||||||
async startVoice() {
|
async startVoice() {
|
||||||
if (this.voiceLoop) return;
|
if (this.voiceLoop) return;
|
||||||
const voiceIO = new QvacVoiceAdapter();
|
const voiceIO = new QvacVoiceAdapter();
|
||||||
this.voiceLoop = new VoiceLoop({ daemon: this, wake: createWakeEngine(), asr: voiceIO, tts: voiceIO });
|
const loop = new VoiceLoop({ daemon: this, wake: createWakeEngine(), asr: voiceIO, tts: voiceIO });
|
||||||
try { await this.voiceLoop.start(); } catch (error) { this.voiceLoop = null; this.emit('Error', 'VOICE_UNAVAILABLE', error.message); throw error; }
|
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)); }
|
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?.metrics?.snapshot?.() || null, p2p: { enabled: process.env.JARVIS_P2P_ENABLE === '1', inference: false, memorySync: false } }); }
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { acquireQvac, releaseQvac, loadAuxiliaryModel, unloadAuxiliaryModel, qvacSdk, withQvacMaster, assertSdkVersion } from './qvac-master.js';
|
import { acquireQvac, releaseQvac, loadAuxiliaryModel, unloadAuxiliaryModel, qvacSdk, withQvacMaster, assertSdkVersion } from './qvac-master.js';
|
||||||
|
|
||||||
|
export function pcmS16le(samples, sampleRate = 44_100) {
|
||||||
|
if (samples instanceof Int16Array) return { samples, sampleRate };
|
||||||
|
const bytes = toUint8(samples);
|
||||||
|
const even = bytes.byteLength - (bytes.byteLength % 2);
|
||||||
|
const copy = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + even);
|
||||||
|
return { samples: new Int16Array(copy), sampleRate };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUint8(samples) {
|
||||||
|
if (!samples) return new Uint8Array(0);
|
||||||
|
if (samples instanceof ArrayBuffer) return new Uint8Array(samples);
|
||||||
|
if (ArrayBuffer.isView(samples)) return new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
|
||||||
|
if (typeof samples === 'string') return Uint8Array.from(Buffer.from(samples));
|
||||||
|
if (Array.isArray(samples) || samples.length != null) return Uint8Array.from(Buffer.from(samples));
|
||||||
|
return new Uint8Array(0);
|
||||||
|
}
|
||||||
|
|
||||||
export class QvacVoiceAdapter extends EventEmitter {
|
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' } = {}) {
|
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;
|
super(); this.asrModel = asrModel; this.ttsModel = ttsModel; this.asrId = null; this.ttsId = null; this.asrSession = null; this.acquired = false;
|
||||||
@@ -33,10 +50,16 @@ export class QvacVoiceAdapter extends EventEmitter {
|
|||||||
async speak(text) {
|
async speak(text) {
|
||||||
const sdk = await qvacSdk();
|
const sdk = await qvacSdk();
|
||||||
const samples = await withQvacMaster(async () => {
|
const samples = await withQvacMaster(async () => {
|
||||||
const result = sdk.textToSpeech({ modelId: this.ttsId, text: String(text), inputType: 'text', stream: false });
|
const result = await sdk.textToSpeech({ modelId: this.ttsId, text: String(text), inputType: 'text', stream: false });
|
||||||
return result.buffer;
|
if (result?.buffer != null) return await result.buffer;
|
||||||
|
if (result?.bufferStream) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of result.bufferStream) chunks.push(Buffer.from(chunk));
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
return { samples: Int16Array.from(samples), sampleRate: 44_100 };
|
return pcmS16le(samples, 44_100);
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
|
|||||||
+21
-4
@@ -22,7 +22,7 @@ export function fastCommand(text) { return FAST_COMMANDS.get(String(text || '').
|
|||||||
export class VoiceLoop extends EventEmitter {
|
export class VoiceLoop extends EventEmitter {
|
||||||
constructor({ daemon, capture = new PipeWireCapture(), playback = new PipeWirePlayback(), wake = new WakeEngine(), vad = new VadSegmenter(), asr, tts, cooldownMs = POST_PLAYBACK_COOLDOWN_MS, now = () => Date.now() } = {}) {
|
constructor({ daemon, capture = new PipeWireCapture(), playback = new PipeWirePlayback(), wake = new WakeEngine(), vad = new VadSegmenter(), asr, tts, cooldownMs = POST_PLAYBACK_COOLDOWN_MS, now = () => Date.now() } = {}) {
|
||||||
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;
|
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._speechQueue = Promise.resolve(); this.metrics = new VoiceMetrics();
|
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('audio', (chunk) => this.pushAudio(chunk));
|
||||||
capture.on('error', (error) => this.emit('error', error));
|
capture.on('error', (error) => this.emit('error', error));
|
||||||
wake.on('wake', (phrase) => this.wakeHeard(phrase));
|
wake.on('wake', (phrase) => this.wakeHeard(phrase));
|
||||||
@@ -58,12 +58,29 @@ export class VoiceLoop extends EventEmitter {
|
|||||||
if (command === 'screen') { await this.daemon?.ask?.('What is on my screen?'); return; }
|
if (command === 'screen') { await this.daemon?.ask?.('What is on my screen?'); return; }
|
||||||
await this.daemon?.ask?.(text);
|
await this.daemon?.ask?.(text);
|
||||||
}
|
}
|
||||||
|
interrupt() {
|
||||||
|
this._generation += 1;
|
||||||
|
this.playback.stop();
|
||||||
|
this.isSpeaking = false;
|
||||||
|
this.wake.resume();
|
||||||
|
}
|
||||||
|
_releaseSpeaking() {
|
||||||
|
this.isSpeaking = false;
|
||||||
|
this.wake.resume();
|
||||||
|
if (this.daemon?.state === 'SPEAKING') { this.daemon.voice?.finishSpeaking?.(); this.daemon.setState?.('LISTENING'); }
|
||||||
|
}
|
||||||
async speak(text) {
|
async speak(text) {
|
||||||
if (!isMeaningfulTranscript(text) || !this.tts?.speak) return;
|
const generation = this._generation || 0;
|
||||||
|
if (!isMeaningfulTranscript(text) || !this.tts?.speak) { this._releaseSpeaking(); return; }
|
||||||
this.metrics.reply();
|
this.metrics.reply();
|
||||||
const sentences = String(text).match(/[^.!?]+[.!?]+|[^.!?]+$/g)?.map((part) => part.trim()).filter(isMeaningfulTranscript) || [];
|
const sentences = String(text).match(/[^.!?]+[.!?]+|[^.!?]+$/g)?.map((part) => part.trim()).filter(isMeaningfulTranscript) || [];
|
||||||
this._speechQueue = this._speechQueue.then(async () => {
|
if (!sentences.length) { this._releaseSpeaking(); return; }
|
||||||
for (const sentence of sentences) await this.speakSentence(sentence);
|
this._speechQueue = this._speechQueue.catch(() => {}).then(async () => {
|
||||||
|
if (generation !== this._generation) return;
|
||||||
|
for (const sentence of sentences) {
|
||||||
|
if (generation !== this._generation) return;
|
||||||
|
await this.speakSentence(sentence);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return this._speechQueue;
|
return this._speechQueue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export class VoiceStateMachine {
|
|||||||
transition(next, reason = 'unspecified') {
|
transition(next, reason = 'unspecified') {
|
||||||
if (!STATES.includes(next)) throw new Error(`unknown Jarvis state: ${next}`);
|
if (!STATES.includes(next)) throw new Error(`unknown Jarvis state: ${next}`);
|
||||||
const allowed = {
|
const allowed = {
|
||||||
ARMED: ['LISTENING', 'SLEEPING'], LISTENING: ['THINKING', 'ARMED', 'SLEEPING'],
|
ARMED: ['LISTENING', 'SLEEPING'], LISTENING: ['THINKING', 'ARMED', 'SLEEPING', 'SPEAKING'],
|
||||||
THINKING: ['SPEAKING', 'ARMED', 'LISTENING'], SPEAKING: ['LISTENING', 'ARMED'],
|
THINKING: ['SPEAKING', 'ARMED', 'LISTENING'], SPEAKING: ['LISTENING', 'ARMED', 'THINKING'],
|
||||||
SLEEPING: ['LISTENING', 'ARMED'],
|
SLEEPING: ['LISTENING', 'ARMED'],
|
||||||
};
|
};
|
||||||
if (next !== this.state && !allowed[this.state].includes(next)) {
|
if (next !== this.state && !allowed[this.state].includes(next)) {
|
||||||
|
|||||||
@@ -15,3 +15,13 @@ export function parseHudSidecar(text) {
|
|||||||
try { hud = JSON.parse(match[1]); } catch { hud = { error: 'invalid hud sidecar' }; }
|
try { hud = JSON.parse(match[1]); } catch { hud = { error: 'invalid hud sidecar' }; }
|
||||||
return { spoken: source.replace(match[0], '').trim(), hud };
|
return { spoken: source.replace(match[0], '').trim(), hud };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function spokenReply(reply) {
|
||||||
|
if (reply == null) return '';
|
||||||
|
const text = typeof reply === 'string' ? reply
|
||||||
|
: typeof reply.text === 'string' ? reply.text
|
||||||
|
: typeof reply.message === 'string' ? reply.message
|
||||||
|
: '';
|
||||||
|
if (!text || text === '[object Object]') return '';
|
||||||
|
return parseHudSidecar(text).spoken;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { VoiceStateMachine } from '../daemon/voice-state.js';
|
import { VoiceStateMachine } from '../daemon/voice-state.js';
|
||||||
import { QvacScheduler } from '../daemon/qvac-scheduler.js';
|
import { QvacScheduler } from '../daemon/qvac-scheduler.js';
|
||||||
|
import { JarvisDaemon } from '../daemon/index.js';
|
||||||
|
import { spokenReply } from '../skills/voice-prompt.js';
|
||||||
|
|
||||||
test('voice state machine handles wake, reply, cancel, and idle sleep', () => {
|
test('voice state machine handles wake, reply, cancel, and idle sleep', () => {
|
||||||
let now = 0;
|
let now = 0;
|
||||||
@@ -20,6 +22,37 @@ test('typed questions wake an armed voice session before thinking', () => {
|
|||||||
assert.equal(voice.state, 'THINKING');
|
assert.equal(voice.state, 'THINKING');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('typed questions barge in from SPEAKING', () => {
|
||||||
|
const voice = new VoiceStateMachine();
|
||||||
|
voice.wake(); voice.utterance(); voice.speak();
|
||||||
|
voice.typedUtterance();
|
||||||
|
assert.equal(voice.state, 'THINKING');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spoken replies use harness text and strip HUD sidecars', () => {
|
||||||
|
assert.equal(spokenReply({ ok: true, text: 'Hello there.', reason: 'stop' }), 'Hello there.');
|
||||||
|
assert.equal(spokenReply({ text: 'Spoken. <jarvis_hud>{"title":"x","chips":[]}</jarvis_hud>' }), 'Spoken.');
|
||||||
|
assert.equal(spokenReply({}), '');
|
||||||
|
assert.equal(spokenReply('[object Object]'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ask extracts harness reply text instead of stringifying the object', async () => {
|
||||||
|
const daemon = new JarvisDaemon();
|
||||||
|
daemon.harness = { ask: async () => ({ ok: true, text: 'Hello there.', reason: 'stop' }), cancel() {}, close: async () => {} };
|
||||||
|
daemon.voiceLoop = null;
|
||||||
|
const replies = [];
|
||||||
|
daemon.on('Reply', (text) => replies.push(text));
|
||||||
|
try {
|
||||||
|
const result = await daemon.ask('Hi');
|
||||||
|
assert.equal(result, 'Hello there.');
|
||||||
|
assert.deepEqual(replies, ['Hello there.']);
|
||||||
|
assert.equal(daemon.lastReply, 'Hello there.');
|
||||||
|
assert.equal(daemon.state, 'LISTENING');
|
||||||
|
} finally {
|
||||||
|
await daemon.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('QVAC scheduler prioritizes voice and keeps one active job', async () => {
|
test('QVAC scheduler prioritizes voice and keeps one active job', async () => {
|
||||||
const scheduler = new QvacScheduler();
|
const scheduler = new QvacScheduler();
|
||||||
const order = [];
|
const order = [];
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('../apps/gnome-extension/[email protected]/extension.js', import.meta.url), 'utf8');
|
||||||
|
function harness() {
|
||||||
|
const timers = new Map();
|
||||||
|
class Actor {
|
||||||
|
constructor(props = {}) {
|
||||||
|
if ('hexpand' in props) throw new Error('No property hexpand on StWidget');
|
||||||
|
Object.assign(this, props);
|
||||||
|
this.children = [];
|
||||||
|
this.visible = props.visible !== false;
|
||||||
|
this.clutter_text = { connect() {}, ellipsize: null };
|
||||||
|
}
|
||||||
|
add_child(child) { this.children.push(child); }
|
||||||
|
destroy_all_children() { this.children = []; }
|
||||||
|
get_last_child() { return this.children[this.children.length - 1] || null; }
|
||||||
|
connect() { return 1; }
|
||||||
|
hide() { this.visible = false; }
|
||||||
|
show() { this.visible = true; }
|
||||||
|
destroy() { this.destroyed = true; }
|
||||||
|
set_position() {}
|
||||||
|
grab_key_focus() {}
|
||||||
|
set_style() {}
|
||||||
|
add_style_class_name() {}
|
||||||
|
}
|
||||||
|
const context = vm.createContext({
|
||||||
|
Extension: class {},
|
||||||
|
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 } },
|
||||||
|
Main: { layoutManager: { addChrome() {} } },
|
||||||
|
GLib: {
|
||||||
|
PRIORITY_DEFAULT: 0, SOURCE_REMOVE: false,
|
||||||
|
timeout_add(_priority, _delay, callback) { const id = timers.size + 1; timers.set(id, callback); return id; },
|
||||||
|
Source: { remove(id) { timers.delete(id); } },
|
||||||
|
},
|
||||||
|
log() {},
|
||||||
|
});
|
||||||
|
vm.runInContext(source.replace(/^import .*;\n/gm, '').replace('export default class', 'class') +
|
||||||
|
'\nglobalThis.classes = { ArcOverlay, JarvisExtension, JarvisProxy };', context);
|
||||||
|
return { ...context.classes, timers };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('overlay constructs and destroys with pending halo animation', () => {
|
||||||
|
const { ArcOverlay, timers } = harness();
|
||||||
|
const overlay = new ArcOverlay();
|
||||||
|
assert.equal(overlay.header.children[1].x_expand, true);
|
||||||
|
overlay.attach();
|
||||||
|
overlay.showHalo();
|
||||||
|
overlay.showHalo();
|
||||||
|
assert.equal(timers.size, 1);
|
||||||
|
overlay.destroy();
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty chrome widgets start hidden', () => {
|
||||||
|
const { ArcOverlay } = harness();
|
||||||
|
const overlay = new ArcOverlay();
|
||||||
|
assert.equal(overlay.job.visible, false);
|
||||||
|
assert.equal(overlay.target.visible, false);
|
||||||
|
assert.equal(overlay.cursor.visible, false);
|
||||||
|
assert.equal(overlay.wave.visible, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reply finalizes a streaming row instead of duplicating [object Object]', () => {
|
||||||
|
const { ArcOverlay } = harness();
|
||||||
|
const overlay = new ArcOverlay();
|
||||||
|
overlay.token('Hello! I am ready.');
|
||||||
|
overlay.finalizeReply('[object Object]');
|
||||||
|
overlay.finalizeReply({ text: 'ignored duplicate' });
|
||||||
|
assert.equal(overlay.transcript.children.length, 1);
|
||||||
|
assert.match(overlay.transcript.children[0].text, /Hello! I am ready/);
|
||||||
|
assert.doesNotMatch(overlay.transcript.children[0].text, /object Object/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addRow and token coerce objects to readable text', () => {
|
||||||
|
const { ArcOverlay } = harness();
|
||||||
|
const overlay = new ArcOverlay();
|
||||||
|
overlay.addRow('U', { text: 'Hi there' });
|
||||||
|
overlay.token({ text: 'Hello from Jarvis' });
|
||||||
|
assert.equal(overlay.transcript.children.length, 2);
|
||||||
|
assert.match(overlay.transcript.children[0].text, /Hi there/);
|
||||||
|
assert.match(overlay.transcript.children[1].text, /Hello from Jarvis/);
|
||||||
|
assert.doesNotMatch(overlay.transcript.children.map((row) => row.text).join('\n'), /object Object/);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const fails of [false, true]) {
|
||||||
|
test('daemon connection finishing after disable is ignored: ' + (fails ? 'failure' : 'success'), async () => {
|
||||||
|
const { JarvisExtension } = harness();
|
||||||
|
const extension = new JarvisExtension();
|
||||||
|
let finish;
|
||||||
|
extension.proxy = { connect: () => new Promise((resolve, reject) => { finish = fails ? reject : resolve; }) };
|
||||||
|
const pending = extension._connectDaemon();
|
||||||
|
extension.proxy = null;
|
||||||
|
extension.overlay = null;
|
||||||
|
finish(fails ? new Error('Disconnected') : undefined);
|
||||||
|
await pending;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GNOME GI imports expose default namespaces and panel has a real menu', () => {
|
||||||
|
assert.match(source, /import Shell from 'gi:\/\/Shell'/);
|
||||||
|
assert.match(source, /import Meta from 'gi:\/\/Meta'/);
|
||||||
|
assert.match(source, /import Pango from 'gi:\/\/Pango'/);
|
||||||
|
assert.match(source, /new PanelMenu.Button\(0.0, 'Jarvis QVAC', false\)/);
|
||||||
|
assert.match(source, /PushToTalk/);
|
||||||
|
assert.match(source, /finalizeReply/);
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { VoiceLoop, fastCommand } from '../daemon/voice-loop.js';
|
import { VoiceLoop, fastCommand } from '../daemon/voice-loop.js';
|
||||||
|
import { pcmS16le } from '../daemon/voice-adapters.js';
|
||||||
import { WakeEngine } from '../daemon/wake-engine.js';
|
import { WakeEngine } from '../daemon/wake-engine.js';
|
||||||
import { VadSegmenter } from '../daemon/vad.js';
|
import { VadSegmenter } from '../daemon/vad.js';
|
||||||
import { PipeWireCapture, pcmRms } from '../daemon/audio-pipewire.js';
|
import { PipeWireCapture, pcmRms } from '../daemon/audio-pipewire.js';
|
||||||
@@ -53,3 +54,29 @@ test('Phase 4 PipeWire capture uses a named 16 kHz mono node', () => {
|
|||||||
assert.deepEqual(args, ['--record', '--raw', '--format', 's16', '--rate', '16000', '--channels', '1', '--name', 'Jarvis']);
|
assert.deepEqual(args, ['--record', '--raw', '--format', 's16', '--rate', '16000', '--channels', '1', '--name', 'Jarvis']);
|
||||||
capture.stop();
|
capture.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('empty TTS still leaves the daemon listening', async () => {
|
||||||
|
const daemon = new EventEmitter();
|
||||||
|
daemon.state = 'SPEAKING';
|
||||||
|
daemon.voice = { finishSpeaking() { daemon.finished = true; } };
|
||||||
|
daemon.setState = (state) => { daemon.state = state; };
|
||||||
|
const capture = new EventEmitter(); capture.start = () => {}; capture.stop = () => {};
|
||||||
|
const wake = new WakeEngine({ detect: () => null });
|
||||||
|
const loop = new VoiceLoop({ daemon, capture, wake, vad: new VadSegmenter(), tts: { speak: async () => ({ samples: new Int16Array(0) }) } });
|
||||||
|
await loop.speak('[object Object]');
|
||||||
|
assert.equal(daemon.state, 'LISTENING');
|
||||||
|
assert.equal(daemon.finished, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PCM packing uses even s16le sample pairs', () => {
|
||||||
|
const buf = Buffer.alloc(4);
|
||||||
|
buf.writeInt16LE(256, 0);
|
||||||
|
buf.writeInt16LE(-2, 2);
|
||||||
|
const packed = pcmS16le(buf);
|
||||||
|
assert.equal(packed.samples.length, 2);
|
||||||
|
assert.equal(packed.samples[0], 256);
|
||||||
|
assert.equal(packed.samples[1], -2);
|
||||||
|
const fromBytes = pcmS16le(Uint8Array.from([0, 1, 0xfe, 0xff]));
|
||||||
|
assert.equal(fromBytes.samples.length, 2);
|
||||||
|
assert.equal(fromBytes.samples[0], 256);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user