2284 lines
111 KiB
Python
2284 lines
111 KiB
Python
import platform as _platform
|
||
import subprocess as _subprocess
|
||
|
||
# ── Nuclear: force CREATE_NO_WINDOW on EVERY subprocess call on Windows ───────
|
||
# This patches Popen itself, so no per-file flag is needed anywhere.
|
||
if _platform.system() == "Windows":
|
||
_OrigPopen = _subprocess.Popen
|
||
|
||
class _Popen(_OrigPopen):
|
||
def __init__(self, args, **kw):
|
||
kw["creationflags"] = kw.get("creationflags", 0) | _subprocess.CREATE_NO_WINDOW
|
||
kw.pop("startupinfo", None) # drop any stale/shared STARTUPINFO
|
||
super().__init__(args, ** kw)
|
||
|
||
_subprocess.Popen = _Popen
|
||
|
||
|
||
# ── Console must survive non-UTF-8 code pages ────────────────────────────────
|
||
# Every status line in this file carries an emoji, and on a legacy Windows
|
||
# console the active code page is the system one — cp1254 in Turkey, cp1251 in
|
||
# Russia, cp932 in Japan. Printing an emoji there raises UnicodeEncodeError, and
|
||
# because most of these prints sit inside the receive loop it takes the session
|
||
# down on startup. Reconfiguring to UTF-8 with a replacement fallback costs
|
||
# nothing and makes the app launch the same way in every locale.
|
||
import sys as _sys
|
||
|
||
for _stream in ("stdout", "stderr"):
|
||
try:
|
||
_s = getattr(_sys, _stream, None)
|
||
if _s is not None and hasattr(_s, "reconfigure"):
|
||
_s.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass # pythonw / redirected pipes / anything exotic — never fatal
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
import asyncio
|
||
import re
|
||
import threading
|
||
import time
|
||
import json
|
||
import sys
|
||
import traceback
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
import sounddevice as sd
|
||
import numpy as np
|
||
from google import genai
|
||
from google.genai import types
|
||
from ui import JarvisUI
|
||
from memory.memory_manager import (
|
||
load_memory, update_memory, format_memory_for_prompt,
|
||
save_session_summary, pop_last_session,
|
||
search_memory, set_trim_notifier,
|
||
)
|
||
|
||
# The file-backed tools (open_app, web_search, browser_control, …) are no longer
|
||
# imported or declared here — they self-describe via a TOOL dict in their own
|
||
# actions/*.py file and are auto-discovered by core.action_loader at startup.
|
||
# Only tools that are tied to live-session state stay inline in this file
|
||
# (screen_process, close_camera, save_memory, manage_monitor, shutdown_jarvis,
|
||
# system_status).
|
||
from actions.screen_processor import _capture_camera, _capture_screen
|
||
from actions.system_monitor import SystemMonitor, get_system_status
|
||
from actions.proactive import ProactiveEngine
|
||
from actions.background_monitor import (
|
||
add_monitor, remove_monitor, list_monitors, check_all as monitor_check_all,
|
||
)
|
||
from actions.web_search import _news as _fetch_news_sync
|
||
from memory.config_manager import (
|
||
get_brief_enabled, get_media_resolution, get_proactive_audio_enabled,
|
||
get_push_to_talk_enabled, get_thinking_enabled, get_turn_tuning, get_voice,
|
||
get_wake_word_enabled, save_wake_word_enabled, get_input_device, get_output_device,
|
||
)
|
||
from core.plugin_loader import discover_plugins
|
||
from core import undo as undo_stack
|
||
from core import confirm as confirm_gate
|
||
from core import audio_devices
|
||
from core.action_loader import discover_actions
|
||
from core.echo import EchoGuard
|
||
from core.viseme import VisemeStream
|
||
from core.wake_word import (
|
||
WakeWordDetector, is_ready as wake_is_ready, install_and_download as wake_install,
|
||
)
|
||
|
||
# How long the assistant stays awake with no user speech before it auto-sleeps
|
||
# again (wake-word mode only).
|
||
WAKE_SLEEP_TIMEOUT = 120.0 # seconds (2 minutes)
|
||
|
||
def get_base_dir():
|
||
if getattr(sys, "frozen", False):
|
||
return Path(sys.executable).parent
|
||
return Path(__file__).resolve().parent
|
||
|
||
BASE_DIR = get_base_dir()
|
||
API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json"
|
||
PROMPT_PATH = BASE_DIR / "core" / "prompt.txt"
|
||
LIVE_MODEL = "models/gemini-3.1-flash-live-preview"
|
||
CHANNELS = 1
|
||
SEND_SAMPLE_RATE = 16000
|
||
RECEIVE_SAMPLE_RATE = 24000
|
||
CHUNK_SIZE = 1024
|
||
|
||
# RMS below which 16-bit PCM is treated as room silence; above _LEVEL_FULL it
|
||
# reads as a full-height waveform. Tuned so ordinary speech lands mid-range and
|
||
# the bars still move for a quiet talker — language- and device-independent.
|
||
_LEVEL_FLOOR = 60.0
|
||
_LEVEL_FULL = 2600.0
|
||
|
||
|
||
def _pcm_level(samples) -> float:
|
||
"""Map a block of int16 PCM samples to a 0.0–1.0 loudness level for the HUD
|
||
waveform. Returns 0.0 on empty/invalid input so it can never raise."""
|
||
try:
|
||
x = np.asarray(samples, dtype=np.float32)
|
||
if x.size == 0:
|
||
return 0.0
|
||
rms = float(np.sqrt(np.mean(x * x)))
|
||
except Exception:
|
||
return 0.0
|
||
if rms <= _LEVEL_FLOOR:
|
||
return 0.0
|
||
return min(1.0, (rms - _LEVEL_FLOOR) / (_LEVEL_FULL - _LEVEL_FLOOR))
|
||
|
||
|
||
# ── Viseme extraction ─────────────────────────────────────────────────────────
|
||
# The avatar's mouth used to be driven by one RMS value per ~200 ms write batch,
|
||
# which is five updates a second averaged over a fifth of a second — it could
|
||
# only ever flap. These read the *shape* of each 20 ms slice straight from the
|
||
# spectrum of the audio being played, so no transcript, no forced alignment and
|
||
# no language assumption: it works the same for Turkish and English.
|
||
#
|
||
# Two numbers come out. Openness tracks the first formant — F1 climbs as the jaw
|
||
# drops, so /a/ reads open and /i/ or /u/ read closed. Width tracks the second —
|
||
# F2 is high for spread vowels (/i/, /e/) and low for rounded ones (/u/, /o/).
|
||
# Extra time beyond the device's reported output latency before the microphone
|
||
# is trusted again: covers room decay and the speaker's own settling.
|
||
_TAIL_MARGIN = 0.25
|
||
|
||
_VIS_WIN = 1024 # ~43 ms analysis window at 24 kHz: enough for formants
|
||
_VIS_HOP = 480 # 20 ms between frames, i.e. 50 shapes a second
|
||
|
||
# Delay from handing the first bytes of a reply to an already-running output
|
||
# stream to hearing them: one callback period, plus whatever the DAC adds.
|
||
_FIRST_SOUND = CHUNK_SIZE / RECEIVE_SAMPLE_RATE # ~43 ms
|
||
# How far past the device's own buffer the mouth's timeline may drift before it
|
||
# is re-anchored. The buffer is the hard limit on how much audio can be queued
|
||
# ahead, so anything beyond it plus a margin for clock error is impossible.
|
||
_CURSOR_SLACK = 0.15
|
||
|
||
# Erring early is the safe direction. A viewer tolerates a mouth that moves
|
||
# slightly before the sound far better than one that moves after it — the
|
||
# broadcast limits are about 45 ms of lag against 125 ms of lead — so where
|
||
# this is uncertain it is biased to lead.
|
||
|
||
|
||
def _pcm_visemes(samples, sr: int = 24000):
|
||
"""Slice a PCM block into (level, openness, width) frames, one per 20 ms.
|
||
|
||
Returns [] on anything unexpected — the mouth falls back to loudness-only
|
||
articulation rather than the caller having to handle an error.
|
||
"""
|
||
try:
|
||
x = np.asarray(samples, dtype=np.float32)
|
||
if x.size < _VIS_WIN:
|
||
return []
|
||
win = np.hanning(_VIS_WIN).astype(np.float32)
|
||
freqs = np.fft.rfftfreq(_VIS_WIN, 1.0 / sr)
|
||
b_f1_lo = (freqs >= 150) & (freqs < 450) # F1 of close vowels
|
||
b_f1_hi = (freqs >= 450) & (freqs < 1100) # F1 of open vowels
|
||
b_f2_bk = (freqs >= 600) & (freqs < 1300) # F2 of rounded vowels
|
||
b_f2_fr = (freqs >= 1700) & (freqs < 3200) # F2 of spread vowels
|
||
b_hiss = (freqs >= 3800) & (freqs < 8000) # fricatives
|
||
|
||
# One frame per hop across the *whole* block. Stepping only while a full
|
||
# window fits stopped 1024 - 480 samples short of the end, so a 200 ms
|
||
# batch yielded 160 ms of schedule: the mouth ran out of frames before
|
||
# the audio ran out of sound, and each batch no longer lined up with the
|
||
# end of the one before it. Losing 20 % of every batch is most of why
|
||
# the mouth did not track the words.
|
||
out = []
|
||
for start in range(0, x.size, _VIS_HOP):
|
||
# The level gates closures, so it is measured over exactly this
|
||
# 20 ms and never looks ahead. The spectrum needs a longer window
|
||
# to resolve formants and may be short-filled at the very end.
|
||
level = _pcm_level(x[start:start + _VIS_HOP])
|
||
seg = x[start:start + _VIS_WIN]
|
||
if seg.size < _VIS_WIN:
|
||
seg = np.concatenate([seg, np.zeros(_VIS_WIN - seg.size,
|
||
dtype=np.float32)])
|
||
if level <= 0.0:
|
||
out.append((0.0, 0.0, 0.0))
|
||
continue
|
||
mag = np.abs(np.fft.rfft((seg - seg.mean()) * win))
|
||
f1l, f1h = float(mag[b_f1_lo].sum()), float(mag[b_f1_hi].sum())
|
||
f2b, f2f = float(mag[b_f2_bk].sum()), float(mag[b_f2_fr].sum())
|
||
hiss = float(mag[b_hiss].sum())
|
||
|
||
openness = f1h / (f1l + f1h + 1e-6)
|
||
width = (f2f - f2b) / (f2f + f2b + 1e-6)
|
||
# A wide-open jaw physically cannot purse, so openness damps width.
|
||
# /a/ has a low enough F2 to read as "rounded" on the bands alone;
|
||
# letting openness suppress the width term is what keeps an open
|
||
# vowel from pursing.
|
||
width *= (1.0 - openness) ** 0.8
|
||
# Fricatives are formed with a nearly closed mouth.
|
||
h = hiss / (f1l + f1h + f2b + f2f + hiss + 1e-6)
|
||
openness *= 1.0 - 0.65 * min(1.0, h * 2.5)
|
||
out.append((level,
|
||
float(min(1.0, max(0.0, openness))),
|
||
float(min(1.0, max(-1.0, width)))))
|
||
return out
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _describe_tools(declarations) -> str:
|
||
"""One line per capability, straight from the live tool declarations.
|
||
|
||
Derived rather than written down: the action and plugin registries are
|
||
discovered at startup, so whatever the user has installed is what the model
|
||
is told it can do. Adding a plugin extends this by itself, and removing one
|
||
stops the model from claiming an ability it no longer has.
|
||
"""
|
||
lines = []
|
||
for d in declarations or ():
|
||
try:
|
||
name = d.get("name") if isinstance(d, dict) else getattr(d, "name", None)
|
||
desc = (d.get("description") if isinstance(d, dict)
|
||
else getattr(d, "description", "")) or ""
|
||
except Exception:
|
||
continue
|
||
if not name:
|
||
continue
|
||
desc = " ".join(str(desc).split())
|
||
lines.append(f"- {name}: {desc[:150]}" if desc else f"- {name}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _describe_limits(has_vision: bool, has_mic: bool) -> str:
|
||
"""The other half of self-knowledge: what is out of reach, and why.
|
||
|
||
Derived from how the program is actually built, not from a list of refusals.
|
||
A model that knows its boundaries stops improvising around them, and stating
|
||
them as architecture rather than as rules keeps the answer honest in any
|
||
language.
|
||
"""
|
||
out = [
|
||
"- Anything not listed above is outside your reach. Say so in one clause "
|
||
"and offer the nearest thing you can actually do — never mime an action "
|
||
"you cannot take, and never report a result you did not get.",
|
||
"- You act on this machine only. You cannot reach the user's other "
|
||
"devices, accounts or hardware except through the tools listed above.",
|
||
"- You remember what is in the memory block and what has been said this "
|
||
"session. Anything else you were told before is gone unless it was saved.",
|
||
]
|
||
if has_vision:
|
||
out.append(
|
||
"- Your sight is not continuous. You see nothing until you call a "
|
||
"vision tool, and then only that single frame at that moment — you "
|
||
"cannot watch, monitor or notice something changing on screen.")
|
||
else:
|
||
out.append("- You have no sight at all in this build.")
|
||
if has_mic:
|
||
out.append(
|
||
"- You hear nothing while the microphone is muted, and you cannot "
|
||
"unmute it yourself.")
|
||
return "\n".join(out)
|
||
|
||
|
||
def _render_prompt(template: str, values: dict) -> str:
|
||
"""Fill {tokens} in the prompt template.
|
||
|
||
A plain replace rather than str.format: the file is meant to be edited by
|
||
hand, and a stray brace in someone's own wording must never take the app
|
||
down at startup.
|
||
"""
|
||
out = template or ""
|
||
for key, val in values.items():
|
||
out = out.replace("{" + key + "}", str(val))
|
||
return out
|
||
|
||
|
||
def _get_api_key() -> str:
|
||
with open(API_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||
return json.load(f)["gemini_api_key"]
|
||
|
||
|
||
def _load_system_prompt() -> str:
|
||
try:
|
||
return PROMPT_PATH.read_text(encoding="utf-8")
|
||
except Exception:
|
||
return (
|
||
"You are JARVIS, Tony Stark's AI assistant. "
|
||
"Be concise, direct, and always use the provided tools to complete tasks. "
|
||
"Never simulate or guess results — always call the appropriate tool."
|
||
)
|
||
|
||
_CTRL_RE = re.compile(r"<ctrl\d+>", re.IGNORECASE)
|
||
|
||
# Transcript chunks shorter than this may legitimately repeat ("evet, evet"),
|
||
# so only longer ones are treated as duplicates.
|
||
_REPEAT_MIN = 12
|
||
|
||
|
||
def _is_repeat_chunk(txt: str, buf: list) -> bool:
|
||
"""True if this transcript chunk has already been seen this turn.
|
||
|
||
Guards against the API re-sending the tail of a response across the several
|
||
turn_completes a tool-using turn produces.
|
||
"""
|
||
if len(txt) < _REPEAT_MIN:
|
||
return bool(buf) and txt == buf[-1]
|
||
joined = " ".join(buf)
|
||
return txt in joined
|
||
|
||
def _clean_transcript(text: str) -> str:
|
||
text = _CTRL_RE.sub("", text)
|
||
text = re.sub(r"[\x00-\x08\x0b-\x1f]", "", text)
|
||
return text.strip()
|
||
|
||
TOOL_DECLARATIONS = [
|
||
# ── Inline tools ─────────────────────────────────────────────────────────
|
||
# These stay here (rather than in an actions/*.py TOOL dict) because their
|
||
# handling is woven into live-session state — vision capture/injection,
|
||
# camera stream, memory writes, the monitor engine, and shutdown. All other
|
||
# tools live in their own action file and are auto-discovered by
|
||
# core.action_loader (see JarvisLive.__init__).
|
||
{
|
||
"name": "system_status",
|
||
"description": (
|
||
"Returns real-time system metrics: CPU usage, RAM, GPU load, CPU temperature, "
|
||
"uptime, and process count. Use when the user asks about computer performance, "
|
||
"temperature, memory, or resource usage."
|
||
),
|
||
"parameters": {
|
||
"type": "OBJECT",
|
||
"properties": {},
|
||
}
|
||
},
|
||
{
|
||
"name": "screen_process",
|
||
"description": (
|
||
"Captures the screen or webcam image and lets you analyze it. "
|
||
"MUST be called when user asks what is on screen, what you see, "
|
||
"look at camera, analyze my screen, etc. "
|
||
"You have NO visual ability without this tool. "
|
||
"After the image is captured it is sent directly to you — describe what you see and answer the user's question. "
|
||
"When using camera: the live view stays open until user says close it or calls close_camera."
|
||
),
|
||
"parameters": {
|
||
"type": "OBJECT",
|
||
"properties": {
|
||
"angle": {"type": "STRING", "description": "'screen' to capture display, 'camera' for webcam. Default: 'screen'"},
|
||
"text": {"type": "STRING", "description": "The question or instruction about the captured image"}
|
||
},
|
||
"required": ["text"]
|
||
}
|
||
},
|
||
{
|
||
"name": "close_camera",
|
||
"description": (
|
||
"Closes the live camera view shown on screen. "
|
||
"Call when the user says (in ANY language): close camera, stop camera, "
|
||
"turn off camera, that's creepy, etc."
|
||
),
|
||
"parameters": {"type": "OBJECT", "properties": {}, "required": []}
|
||
},
|
||
{
|
||
"name": "manage_monitor",
|
||
"description": (
|
||
"Add, remove, or list background monitoring topics. "
|
||
"JARVIS checks these topics once a day and alerts the user when there is a new development. "
|
||
"Use 'add' when the user says 'monitor X', 'track X', 'follow X'. "
|
||
"Use 'remove' when the user says 'stop monitoring X'. "
|
||
"Use 'list' when the user asks what is being monitored. "
|
||
"Do NOT add crypto, financial, or trading topics."
|
||
),
|
||
"parameters": {
|
||
"type": "OBJECT",
|
||
"properties": {
|
||
"action": {
|
||
"type": "STRING",
|
||
"description": "add | remove | list",
|
||
},
|
||
"topic": {
|
||
"type": "STRING",
|
||
"description": "Topic to monitor or stop monitoring (e.g. 'space exploration', 'AI news')",
|
||
},
|
||
},
|
||
"required": ["action"],
|
||
},
|
||
},
|
||
{
|
||
"name": "shutdown_jarvis",
|
||
"description": (
|
||
"Shuts down the assistant completely. "
|
||
"Call this when the user expresses intent to end the conversation, "
|
||
"close the assistant, say goodbye, or stop Jarvis. "
|
||
"The user can say this in ANY language."
|
||
),
|
||
"parameters": {
|
||
"type": "OBJECT",
|
||
"properties": {},
|
||
}
|
||
},
|
||
{
|
||
"name": "save_memory",
|
||
"description": (
|
||
"Save an important personal fact about the user to long-term memory. "
|
||
"Call this silently whenever the user reveals something worth remembering: "
|
||
"name, age, city, job, preferences, hobbies, relationships, projects, or future plans. "
|
||
"Do NOT call for: weather, reminders, searches, or one-time commands. "
|
||
"Do NOT announce that you are saving — just call it silently. "
|
||
"Values must be in English regardless of the conversation language."
|
||
),
|
||
"parameters": {
|
||
"type": "OBJECT",
|
||
"properties": {
|
||
"category": {
|
||
"type": "STRING",
|
||
"description": (
|
||
"identity — name, age, birthday, city, job, language, nationality | "
|
||
"preferences — favorite food/color/music/film/game/sport, hobbies | "
|
||
"projects — active projects, goals, things being built | "
|
||
"relationships — friends, family, partner, colleagues | "
|
||
"wishes — future plans, things to buy, travel dreams | "
|
||
"notes — habits, schedule, anything else worth remembering"
|
||
)
|
||
},
|
||
"key": {"type": "STRING", "description": "Short snake_case key (e.g. name, favorite_food, sister_name)"},
|
||
"value": {"type": "STRING", "description": "Concise value in English (e.g. Fatih, pizza, older sister)"},
|
||
},
|
||
"required": ["category", "key", "value"]
|
||
}
|
||
},
|
||
{
|
||
"name": "recall_memory",
|
||
"description": (
|
||
"Look up a fact you have stored about the user but which is NOT in "
|
||
"the memory block of your system prompt. "
|
||
"The prompt lists the keys it did not have room for under "
|
||
"'[ALSO REMEMBERED]' — if the user asks about anything named there, "
|
||
"call this FIRST. "
|
||
"Also call it before saying you do not know something personal, and "
|
||
"when the user asks what you remember about them (leave query empty "
|
||
"for everything). "
|
||
"This is a local file search: it is instant and costs nothing."
|
||
),
|
||
"parameters": {
|
||
"type": "OBJECT",
|
||
"properties": {
|
||
"query": {
|
||
"type": "STRING",
|
||
"description": (
|
||
"Keyword to search for — a name, a topic, a category "
|
||
"(e.g. 'ayse', 'coffee', 'projects'). "
|
||
"Leave empty to list everything stored."
|
||
),
|
||
},
|
||
},
|
||
"required": [],
|
||
},
|
||
},
|
||
{
|
||
"name": "undo",
|
||
"description": (
|
||
"Reverse the last change YOU made to this computer — a file you "
|
||
"moved, renamed, created or wrote, or a setting you changed such as "
|
||
"volume, brightness, dark mode or WiFi. "
|
||
"Call this whenever the user says undo, revert, take it back, put it "
|
||
"back, cancel that, or tells you that you did the wrong thing, in ANY "
|
||
"language. "
|
||
"Use action='list' when they ask what can be undone. "
|
||
"This only covers your own actions — it is not the Ctrl+Z of whatever "
|
||
"application is on screen (that is computer_settings with action 'undo')."
|
||
),
|
||
"parameters": {
|
||
"type": "OBJECT",
|
||
"properties": {
|
||
"action": {
|
||
"type": "STRING",
|
||
"description": "undo (default) — reverse the last change | list — show what can be undone",
|
||
},
|
||
},
|
||
"required": [],
|
||
},
|
||
},
|
||
]
|
||
|
||
class _ReconnectSignal(Exception):
|
||
"""Raised inside the session TaskGroup to force a clean, voluntary reconnect
|
||
(e.g. the user picked a new voice — the voice is fixed at connect time, so
|
||
the session must be rebuilt).
|
||
|
||
Carries `keep_context`: True for an ordinary rebuild, where the stored
|
||
resumption handle is replayed and the conversation continues; False when the
|
||
new session must genuinely start clean (see the voice-change note in
|
||
_on_voice_change)."""
|
||
|
||
def __init__(self, keep_context: bool = True):
|
||
super().__init__()
|
||
self.keep_context = keep_context
|
||
|
||
|
||
def _is_reconnect_signal(exc: BaseException) -> bool:
|
||
"""True if `exc` is a _ReconnectSignal, or a(n) (Base)ExceptionGroup that
|
||
wraps one — TaskGroup bundles child exceptions into a group."""
|
||
if isinstance(exc, _ReconnectSignal):
|
||
return True
|
||
if isinstance(exc, BaseExceptionGroup):
|
||
return any(_is_reconnect_signal(sub) for sub in exc.exceptions)
|
||
return False
|
||
|
||
|
||
def _keep_context_of(exc: BaseException) -> bool:
|
||
"""Read `keep_context` off a reconnect signal, unwrapping the group the
|
||
TaskGroup put it in. Defaults to True: an unexpected shape must not silently
|
||
wipe the conversation."""
|
||
if isinstance(exc, _ReconnectSignal):
|
||
return getattr(exc, "keep_context", True)
|
||
if isinstance(exc, BaseExceptionGroup):
|
||
for sub in exc.exceptions:
|
||
if _is_reconnect_signal(sub):
|
||
return _keep_context_of(sub)
|
||
return True
|
||
|
||
|
||
class JarvisLive:
|
||
def __init__(self, ui: JarvisUI):
|
||
self.ui = ui
|
||
self._asst_name = "JARVI S" # updated each session from config
|
||
self.session = None
|
||
self.audio_in_queue = None
|
||
self.out_queue = None
|
||
self._loop = None
|
||
self._is_speaking = False
|
||
self._speaking_lock = threading.Lock()
|
||
self._phone_active = False # True while phone mic is streaming; pauses PC mic
|
||
self._pending_vision = None # (img_bytes, mime_type, question, angle) to inject after tool response
|
||
self._vision_cam_active = False # True if camera was opened for vision → auto-close after response
|
||
self._vision_close_pending = False # True after vision injected; next turn_complete closes camera
|
||
self._vision_last_time = 0.0 # monotonic time of last screen_process call (cooldown guard)
|
||
self._vision_busy = False # True while a vision capture/inject cycle is in flight
|
||
self._interrupted = False # True while draining audio after user interrupt
|
||
# Transcript-driven mouth shapes for the avatar. Fed from the receive
|
||
# loop as words arrive, drained by the playback loop against the audio.
|
||
self._visemes = VisemeStream()
|
||
self._last_out_logged = "" # de-dupes a re-sent transcript tail
|
||
# Push-to-talk
|
||
self._ptt_enabled = False
|
||
self._ptt_held = False
|
||
self._ptt = None # core.hotkey.PushToTalk
|
||
self._out_level = 0.0 # level of the audio being played right now
|
||
self._echo = EchoGuard()
|
||
# `stream.write()` returns when the buffer accepts the audio, not when the
|
||
# speaker has finished with it, so sound is still in the room after the
|
||
# speaking flag drops. Streaming the microphone during that gap is how an
|
||
# assistant ends up answering itself. Measured from the device rather than
|
||
# guessed; see _play_audio.
|
||
self._out_latency = 0.20 # seconds, replaced with the real value
|
||
self._tail_until = 0.0 # monotonic time the echo tail expires
|
||
# Wall-clock time at which the audio written next will begin to sound.
|
||
# The mouth is scheduled against this, never against "now": batches are
|
||
# handed to the device far faster than they play, so "now" ran the lips
|
||
# ahead of the words and cut every schedule short. 0 = nothing playing.
|
||
self._play_cursor = 0.0
|
||
self.ui.on_push_to_talk = self.set_push_to_talk
|
||
self.ui.ptt_hold = self._on_ptt
|
||
self.ui.on_text_command = self._on_text_command
|
||
self.ui.on_remote_clicked = self._make_remote_key
|
||
self.ui.on_interrupt = self.interrupt
|
||
self.ui.on_voice_change = self._on_voice_change # voice picker → rebuild session
|
||
self.ui.on_audio_device_change = self._on_audio_device_change
|
||
self._reconnect_event: asyncio.Event | None = None
|
||
self._reconnect_keep = True # False → next rebuild drops the resumption handle
|
||
|
||
# ── Session resumption ─────────────────────────────────────────
|
||
# The server issues a resumption handle every few seconds and reissues
|
||
# it as the conversation moves on. Before this, session_resumption was
|
||
# switched ON in the config and the update was never read, so the handle
|
||
# was thrown away and EVERY reconnect — a dropped packet, a voice change,
|
||
# switching microphone — started an empty session. "Unlimited sessions"
|
||
# leaked through exactly this hole.
|
||
#
|
||
# Deliberately in RAM only, never written to disk. Persisting it would
|
||
# make a fresh launch continue yesterday's conversation, which sounds
|
||
# appealing but breaks the session-summary flow: _save_session_summary
|
||
# runs at shutdown and the morning briefing pops it the next day. A
|
||
# conversation that never ends never produces a summary, and the
|
||
# "yesterday we talked about…" line silently disappears.
|
||
self._resume_handle: str | None = None
|
||
self._turn_done_event: asyncio.Event | None = None
|
||
self._dashboard = None
|
||
self._briefing_sent = False # morning briefing fires once per process
|
||
self._sys_monitor = SystemMonitor() # persistent cooldown state
|
||
self._proactive = ProactiveEngine()
|
||
self._last_user_speech = time.monotonic() # updated on every user utterance
|
||
self._session_log: list[str] = [] # conversation turns for end-of-session summary
|
||
|
||
self._enhanced_live = True # proactive audio; auto-disabled if the server rejects it
|
||
self._tuned_live = True # turn-taking / media / thinking knobs; same fallback
|
||
|
||
_base_dir = Path(__file__).resolve().parent
|
||
_inline_names = {t["name"] for t in TOOL_DECLARATIONS}
|
||
|
||
# File-backed tools: every actions/*.py with a TOOL dict, discovered the
|
||
# same way plugins are. Reserved names = the inline tools above, so an
|
||
# action can never shadow one.
|
||
self._action_registry = discover_actions(
|
||
actions_dir=_base_dir / "actions",
|
||
reserved_names=_inline_names,
|
||
logger=lambda msg: print(f"[Actions] {msg}"),
|
||
)
|
||
|
||
# Plugins must not collide with either an inline tool or a discovered action.
|
||
_core_names = _inline_names | self._action_registry.names()
|
||
self._plugin_registry = discover_plugins(
|
||
plugins_dir=_base_dir / "plugins",
|
||
core_tool_names=_core_names,
|
||
# Console gets the full boot transcript; the activity log gets only
|
||
# what the user has to know about. Every plugin loading correctly is
|
||
# the expected case and does not belong in their conversation.
|
||
logger=lambda msg: print(f"[Plugins] {msg}"),
|
||
notify=lambda msg: self.ui.write_log(f"SYS: {msg}"),
|
||
)
|
||
self.ui.get_plugins = self._plugin_registry.list_for_ui
|
||
self.ui.get_plugin_settings = self._plugin_registry.settings_schemas # ⚙ settings tab
|
||
self.ui.request_say = self.plugin_say # plugins: mid-task speech channel
|
||
|
||
# ── Wake word ────────────────────────────────────────────────────────
|
||
# _awake gates the mic (see _listen_audio) and the background speakers.
|
||
# It is True whenever wake word is OFF, so default behaviour is unchanged.
|
||
self._wake_enabled = get_wake_word_enabled()
|
||
self._awake = not self._wake_enabled
|
||
self._wake_detector: WakeWordDetector | None = None
|
||
self._wake_sleep_timeout = WAKE_SLEEP_TIMEOUT
|
||
|
||
# Restore the saved push-to-talk preference. Doing it here rather than
|
||
# in __init__ means the hotkey thread only exists once there is a
|
||
# session to talk to.
|
||
if get_push_to_talk_enabled():
|
||
try:
|
||
self.set_push_to_talk(True)
|
||
except Exception as e:
|
||
print(f"[JARVIS] ⚠ Push-to-talk unavailable: {e}")
|
||
# UI control surface for the Wake Word settings section.
|
||
self.ui.wake_is_ready = wake_is_ready # () -> bool
|
||
self.ui.wake_get_state = self._wake_state # () -> dict
|
||
self.ui.on_wake_toggle = self._ui_wake_toggle # (enable: bool) -> str
|
||
self.ui.on_wake_manual = self._ui_wake_manual # () -> toggle awake/asleep
|
||
self.ui.on_wake_install = self._ui_wake_install # () -> (ok, msg)
|
||
|
||
# ── Wake word: state machine ─────────────────────────────────────────────
|
||
|
||
def _wake_state(self) -> dict:
|
||
# A loaded, running detector is definitively ready; otherwise fall back
|
||
# to the cheap on-disk model-file check (no Model construction).
|
||
ready = bool(self._wake_detector and self._wake_detector.ready) or wake_is_ready()
|
||
return {"enabled": self._wake_enabled, "awake": self._awake, "ready": ready}
|
||
|
||
def _ensure_wake_detector(self) -> bool:
|
||
"""Load the detector once (model loads on first start). Idempotent."""
|
||
if self._wake_detector is None:
|
||
self._wake_detector = WakeWordDetector(
|
||
on_detect=self._on_wake_detected,
|
||
logger=lambda m: print(f"[Wake] {m}"),
|
||
notify=lambda m: self.ui.write_log(f"SYS: {m}"),
|
||
)
|
||
if not self._wake_detector.ready:
|
||
return self._wake_detector.start()
|
||
return True
|
||
|
||
def _on_wake_detected(self) -> None:
|
||
"""Called from the detector thread when 'Hey Jarvis' is heard."""
|
||
self.wake(reason="wake word")
|
||
|
||
def wake(self, reason: str = "wake word") -> None:
|
||
if self._awake:
|
||
return
|
||
self._awake = True
|
||
self._last_user_speech = time.monotonic() # start the auto-sleep clock now
|
||
if not self.ui.muted:
|
||
self.ui.set_state("LISTENING")
|
||
self.ui.write_log(f"SYS: Awake — {reason}.")
|
||
|
||
def sleep(self, reason: str = "timeout") -> None:
|
||
if not self._awake:
|
||
return
|
||
self._awake = False
|
||
self.set_speaking(False)
|
||
self.ui.set_state("SLEEPING")
|
||
self.ui.write_log(f"SYS: Sleeping — {reason}. Say 'Hey Jarvis' to wake me.")
|
||
|
||
async def _run_sleep_watch(self) -> None:
|
||
"""Auto-sleep after the configured silence window (wake-word mode only)."""
|
||
while True:
|
||
await asyncio.sleep(5)
|
||
if not self._wake_enabled or not self._awake:
|
||
continue
|
||
with self._speaking_lock:
|
||
speaking = self._is_speaking
|
||
if speaking:
|
||
continue
|
||
if (time.monotonic() - self._last_user_speech) > self._wake_sleep_timeout:
|
||
self.sleep(reason="no speech for 2 minutes")
|
||
|
||
# ── Wake word: UI callbacks (called from the Qt thread) ──────────────────
|
||
|
||
def _ui_wake_toggle(self, enable: bool) -> str:
|
||
"""Enable/disable wake word from the settings UI. Returns a status token:
|
||
'enabled' | 'disabled' | 'need_download'."""
|
||
if enable:
|
||
if not wake_is_ready():
|
||
return "need_download"
|
||
self._wake_enabled = True
|
||
save_wake_word_enabled(True)
|
||
self._ensure_wake_detector()
|
||
self.sleep(reason="wake word enabled")
|
||
return "enabled"
|
||
else:
|
||
self._wake_enabled = False
|
||
save_wake_word_enabled(False)
|
||
self.wake(reason="wake word disabled")
|
||
return "disabled"
|
||
|
||
def _ui_wake_manual(self) -> None:
|
||
"""Manual sleep/wake button in the UI."""
|
||
if not self._wake_enabled:
|
||
return
|
||
if self._awake:
|
||
self.sleep(reason="you tapped sleep")
|
||
else:
|
||
self.wake(reason="you tapped wake")
|
||
|
||
def _ui_wake_install(self) -> tuple[bool, str]:
|
||
"""Download openwakeword + the model (runs in a UI worker thread)."""
|
||
# Triggered by the user pressing the button, so its progress is exactly
|
||
# what they are waiting to see.
|
||
return wake_install(logger=lambda m: print(f"[Wake] {m}"),
|
||
notify=lambda m: self.ui.write_log(f"SYS: {m}"))
|
||
|
||
def plugin_say(self, instruction: str) -> None:
|
||
"""
|
||
Thread-safe speech channel for plugins: lets a plugin ask JARVIS to
|
||
say something short WHILE its run() is still executing (plugins block
|
||
their executor thread, so they can't speak through the tool response
|
||
until they finish). The instruction is injected into the Live session
|
||
exactly like a proactive check-in; Gemini phrases it naturally in the
|
||
user's language. Silently a no-op when no session is connected.
|
||
"""
|
||
loop = getattr(self, "_loop", None)
|
||
if not loop or not self.session:
|
||
return
|
||
|
||
async def _say():
|
||
try:
|
||
await self.session.send_client_content(
|
||
turns={"role": "user", "parts": [{"text": instruction}]},
|
||
turn_complete=True,
|
||
)
|
||
except Exception as e:
|
||
print(f"[PluginSay] {e}")
|
||
|
||
try:
|
||
asyncio.run_coroutine_threadsafe(_say(), loop)
|
||
except Exception as e:
|
||
print(f"[PluginSay] {e}")
|
||
|
||
def request_reconnect(self, keep_context: bool = True, reason: str = ""):
|
||
"""Thread-safe: ask the run loop to tear down and rebuild the Live
|
||
session. Called from the Qt thread. No-op until the async loop and
|
||
reconnect event exist.
|
||
|
||
`keep_context=False` drops the resumption handle so the new session
|
||
starts empty — only for changes the server cannot apply to a resumed
|
||
session."""
|
||
loop = getattr(self, "_loop", None)
|
||
ev = self._reconnect_event
|
||
self._reconnect_keep = keep_context
|
||
self._reconnect_reason = reason
|
||
if loop and ev is not None:
|
||
loop.call_soon_threadsafe(ev.set)
|
||
|
||
def _on_voice_change(self):
|
||
"""Voice picker applied.
|
||
|
||
The voice is baked into the session at connect time, so a rebuild is
|
||
required. It is rebuilt WITHOUT the resumption handle on purpose:
|
||
resuming restores the server's own session state, and the safe reading
|
||
is that it restores the voice with it — which would make the picker
|
||
appear to do nothing. Losing context here is acceptable because changing
|
||
voice is a deliberate, rare act; losing it on a dropped packet was not."""
|
||
self.request_reconnect(keep_context=False, reason="new voice")
|
||
|
||
def _on_audio_device_change(self):
|
||
"""Microphone or speaker changed. Both streams are opened inside the
|
||
session TaskGroup, so they can only be re-opened by rebuilding it —
|
||
but the conversation is kept, which is the whole reason resumption
|
||
landed before this feature did."""
|
||
self.request_reconnect(keep_context=True, reason="audio device")
|
||
|
||
async def _watch_reconnect(self):
|
||
"""Session-scoped task: when a voluntary reconnect is requested, raise a
|
||
signal that unwinds the TaskGroup so the run loop rebuilds the session."""
|
||
assert self._reconnect_event is not None
|
||
await self._reconnect_event.wait()
|
||
self._reconnect_event.clear()
|
||
keep = self._reconnect_keep
|
||
reason = getattr(self, "_reconnect_reason", "") or "settings"
|
||
self.ui.write_log(
|
||
f"SYS: Applying {reason} — reconnecting"
|
||
+ ("..." if keep else " (starting a fresh conversation)...")
|
||
)
|
||
raise _ReconnectSignal(keep_context=keep)
|
||
|
||
def _make_remote_key(self):
|
||
"""Called from Qt main thread when user presses Remote Control."""
|
||
if self._dashboard is None:
|
||
self.ui.write_log(
|
||
"SYS: Dashboard unavailable. "
|
||
"Run: pip install fastapi \"uvicorn[standard]\" cryptography"
|
||
)
|
||
return None
|
||
key = self._dashboard.new_key()
|
||
url = self._dashboard.get_url()
|
||
manual = self._dashboard.get_manual_url()
|
||
return url, key, f"{url}/auto-login?key={key}", manual
|
||
|
||
def _on_text_command(self, text: str):
|
||
if not self._loop or not self.session:
|
||
return
|
||
# Respect wake-word sleep: a typed command must not be answered while
|
||
# asleep either (the sleep gate is not just for the mic). Wake first with
|
||
# "Hey Jarvis" or the WAKE NOW button.
|
||
if self._wake_enabled and not self._awake:
|
||
self.ui.write_log("SYS: I'm asleep — say 'Hey Jarvis' or tap WAKE NOW first.")
|
||
return
|
||
asyncio.run_coroutine_threadsafe(
|
||
self.session.send_client_content(
|
||
turns={"role": "user", "parts": [{"text": text}]},
|
||
turn_complete=True
|
||
),
|
||
self._loop
|
||
)
|
||
|
||
def _tail_active(self) -> bool:
|
||
"""True while the speakers may still be finishing our last sentence."""
|
||
return time.monotonic() < self._tail_until
|
||
|
||
def set_speaking(self, value: bool):
|
||
with self._speaking_lock:
|
||
self._is_speaking = value
|
||
if value:
|
||
self._tail_until = 0.0
|
||
else:
|
||
# Hold the guard open across the device's own output latency plus a
|
||
# margin for the room. The microphone is NOT muted during it — the
|
||
# guard still lets a genuine reply through, so answering instantly
|
||
# still works. Only our own echo is dropped.
|
||
self._tail_until = time.monotonic() + self._out_latency + _TAIL_MARGIN
|
||
if not value:
|
||
# The echo history is deliberately NOT cleared here: the tail above
|
||
# still needs it to recognise our own voice. It is dropped when the
|
||
# tail expires. What the guard learned about the room always stays.
|
||
self._out_level = 0.0
|
||
if value:
|
||
self.ui.set_state("SPEAKING")
|
||
elif not self.ui.muted:
|
||
self.ui.set_state("LISTENING")
|
||
|
||
def set_push_to_talk(self, enabled: bool) -> str:
|
||
"""Turn hold-to-talk on or off. Returns the scope actually achieved."""
|
||
from core.hotkey import PushToTalk
|
||
|
||
self._ptt_enabled = bool(enabled)
|
||
self._ptt_held = False
|
||
if not enabled:
|
||
if self._ptt is not None:
|
||
self._ptt.stop()
|
||
self._ptt = None
|
||
return "off"
|
||
|
||
if self._ptt is None:
|
||
self._ptt = PushToTalk(self._on_ptt)
|
||
scope = self._ptt.start()
|
||
# A window-scoped chord is a real limitation, not a detail — say it once
|
||
# in the log so nobody wonders why it does nothing while another app is
|
||
# focused. Reporting it must never be able to undo the thing it reports.
|
||
try:
|
||
self.ui.write_log(
|
||
f"SYS: Push-to-talk on — hold {self._ptt.label}"
|
||
+ ("." if scope == "global"
|
||
else " (works while this window is focused)."))
|
||
except Exception:
|
||
pass
|
||
return scope
|
||
|
||
def _on_ptt(self, held: bool) -> None:
|
||
"""Chord pressed or released — may arrive on the hotkey thread."""
|
||
self._ptt_held = held
|
||
if held:
|
||
# Holding the key is also a way to wake it, so push-to-talk works
|
||
# without having to say the wake word first.
|
||
if self._wake_enabled and not self._awake:
|
||
self._awake = True
|
||
self._last_user_speech = time.monotonic()
|
||
try:
|
||
self.ui.set_state("LISTENING" if held else "SLEEPING")
|
||
except Exception:
|
||
pass
|
||
|
||
def interrupt(self) -> None:
|
||
"""Stop JARVIS mid-speech: drain queued audio and open mic immediately."""
|
||
self._interrupted = True
|
||
q = self.audio_in_queue
|
||
if q:
|
||
drained = 0
|
||
while True:
|
||
try:
|
||
q.get_nowait()
|
||
drained += 1
|
||
except Exception:
|
||
break
|
||
if drained:
|
||
print(f"[JARVIS] ✋ Interrupted — {drained} audio chunks discarded")
|
||
self.set_speaking(False)
|
||
# The words we were about to mouth are never going to be spoken now.
|
||
self._visemes.reset()
|
||
self._play_cursor = 0.0 # next batch starts a fresh timeline
|
||
if self._turn_done_event:
|
||
self._turn_done_event.clear()
|
||
self.ui.write_log("SYS: Interrupted — listening...")
|
||
|
||
def speak(self, text: str):
|
||
if not self._loop or not self.session:
|
||
return
|
||
asyncio.run_coroutine_threadsafe(
|
||
self.session.send_client_content(
|
||
turns={"role": "user", "parts": [{"text": text}]},
|
||
turn_complete=True
|
||
),
|
||
self._loop
|
||
)
|
||
|
||
def speak_error(self, tool_name: str, error: str):
|
||
short = str(error)[:120]
|
||
self.ui.write_log(f"ERR: {tool_name} — {short}")
|
||
self.speak(f"Sir, {tool_name} encountered an error. {short}")
|
||
|
||
def _build_config(self) -> types.LiveConnectConfig:
|
||
from datetime import datetime
|
||
|
||
# Load customization from config
|
||
try:
|
||
_cfg = json.loads(open(API_CONFIG_PATH, encoding="utf-8").read())
|
||
self._asst_name = (_cfg.get("assistant_name") or "JARVIS").strip()
|
||
_user_name = (_cfg.get("user_name") or "").strip()
|
||
except Exception:
|
||
self._asst_name = "JARVIS"
|
||
_user_name = ""
|
||
|
||
memory = load_memory()
|
||
mem_str = format_memory_for_prompt(memory)
|
||
sys_prompt = _load_system_prompt()
|
||
|
||
now = datetime.now()
|
||
time_str = now.strftime("%A, %B %d, %Y — %I:%M %p")
|
||
time_ctx = (
|
||
f"[CURRENT DATE & TIME]\n"
|
||
f"Right now it is: {time_str}\n"
|
||
f"Use this to calculate exact times for reminders.\n\n"
|
||
)
|
||
|
||
# Identity injection — overrides any hardcoded name in prompt.txt
|
||
# Address form is a property of the language being spoken, so it is
|
||
# stated as a principle rather than a two-language lookup — the model
|
||
# already knows the respectful register of whatever language it is in.
|
||
_addr = (f"ADDRESS: Always call the user '{_user_name}'."
|
||
if _user_name
|
||
else 'ADDRESS: Address the user with the ordinary respectful form '
|
||
'for a superior in the language you are currently speaking — '
|
||
'"sir" in English, its everyday equivalent in any other '
|
||
'language. Never an archaic or aristocratic form, and never '
|
||
'the form from a different language than the one you are '
|
||
'speaking in this sentence.')
|
||
identity_ctx = (
|
||
f"[IDENTITY]\n"
|
||
f"Your name is {self._asst_name}. "
|
||
f"Always refer to yourself as {self._asst_name}.\n"
|
||
f"{_addr}\n\n"
|
||
)
|
||
|
||
# Everything the model is told about *itself* is derived here, not
|
||
# written into prompt.txt: the name comes from config, the platform from
|
||
# the host, the capability list from the registries that were just
|
||
# discovered. Rename the assistant, add a plugin or move to another OS
|
||
# and this follows without anyone editing a prompt.
|
||
_all_decls = (TOOL_DECLARATIONS
|
||
+ self._action_registry.get_tool_declarations()
|
||
+ self._plugin_registry.get_tool_declarations())
|
||
_names = {(d.get("name") if isinstance(d, dict) else getattr(d, "name", ""))
|
||
for d in _all_decls}
|
||
sys_prompt = _render_prompt(sys_prompt, {
|
||
"assistant_name": self._asst_name,
|
||
"platform": f"{_platform.system()} {_platform.release()}".strip(),
|
||
"capabilities": _describe_tools(_all_decls),
|
||
"limits": _describe_limits(
|
||
has_vision="screen_process" in _names,
|
||
has_mic=True,
|
||
),
|
||
})
|
||
|
||
parts = [time_ctx, identity_ctx]
|
||
if mem_str:
|
||
parts.append(mem_str)
|
||
parts.append(sys_prompt)
|
||
|
||
cfg = dict(
|
||
response_modalities=["AUDIO"],
|
||
output_audio_transcription={},
|
||
input_audio_transcription={},
|
||
system_instruction="\n".join(parts),
|
||
tools=[{"function_declarations": _all_decls}],
|
||
# Hand back the handle captured from the last session_resumption
|
||
# update. `handle=None` is exactly the old behaviour (ask for
|
||
# handles, start fresh), so the first connect of a run is unchanged.
|
||
session_resumption=types.SessionResumptionConfig(
|
||
handle=self._resume_handle
|
||
),
|
||
# Sliding-window compression: session never dies from a full context
|
||
# window — JARVIS can stay in one conversation for hours
|
||
context_window_compression=types.ContextWindowCompressionConfig(
|
||
sliding_window=types.SlidingWindow(),
|
||
),
|
||
speech_config=types.SpeechConfig(
|
||
voice_config=types.VoiceConfig(
|
||
prebuilt_voice_config=types.PrebuiltVoiceConfig(
|
||
voice_name=get_voice()
|
||
)
|
||
)
|
||
),
|
||
)
|
||
if self._enhanced_live:
|
||
# Proactive audio: JARVIS stays silent when speech isn't addressed
|
||
# to it (background chatter, talking to someone else in the room).
|
||
# (Affective dialog was dropped: gemini-3.1-flash-live does not
|
||
# support it, and it never reliably detected tone in practice.
|
||
# To restore it on a 2.5 native-audio model, add back:
|
||
# cfg["enable_affective_dialog"] = True )
|
||
if get_proactive_audio_enabled():
|
||
cfg["proactivity"] = types.ProactivityConfig(proactive_audio=True)
|
||
|
||
if self._tuned_live:
|
||
cfg.update(self._tuning_config())
|
||
|
||
return types.LiveConnectConfig(**cfg)
|
||
|
||
def _tuning_config(self) -> dict:
|
||
"""The optional knobs, kept apart so one bad field can be dropped wholesale.
|
||
|
||
Every one of these is a preview-API field. If a future model release
|
||
stops accepting any of them the connection fails at setup, so the run
|
||
loop turns `_tuned_live` off and reconnects on the plain config rather
|
||
than leaving the user with an assistant that will not start.
|
||
"""
|
||
out: dict = {}
|
||
|
||
# How long the server waits through a pause before deciding your turn is
|
||
# over. This — not the size of the prompt — is what most of the delay
|
||
# before a reply actually is, and the default has to suit everybody, so
|
||
# it is necessarily cautious.
|
||
turn = get_turn_tuning()
|
||
if turn.get("enabled", True):
|
||
detect = types.AutomaticActivityDetection(
|
||
silence_duration_ms=turn["silence_ms"],
|
||
prefix_padding_ms=turn["prefix_ms"],
|
||
)
|
||
if turn["end_sensitivity"] == "high":
|
||
detect.end_of_speech_sensitivity = types.EndSensitivity.END_SENSITIVITY_HIGH
|
||
elif turn["end_sensitivity"] == "low":
|
||
detect.end_of_speech_sensitivity = types.EndSensitivity.END_SENSITIVITY_LOW
|
||
if turn["start_sensitivity"] == "high":
|
||
detect.start_of_speech_sensitivity = types.StartSensitivity.START_SENSITIVITY_HIGH
|
||
elif turn["start_sensitivity"] == "low":
|
||
detect.start_of_speech_sensitivity = types.StartSensitivity.START_SENSITIVITY_LOW
|
||
out["realtime_input_config"] = types.RealtimeInputConfig(
|
||
automatic_activity_detection=detect)
|
||
|
||
# Screenshots and camera frames are tokenised at this resolution and then
|
||
# stay in the session's context. 'medium' keeps on-screen text legible
|
||
# for a fraction of a full-resolution frame.
|
||
res = get_media_resolution()
|
||
if res != "default":
|
||
out["media_resolution"] = {
|
||
"low": types.MediaResolution.MEDIA_RESOLUTION_LOW,
|
||
"medium": types.MediaResolution.MEDIA_RESOLUTION_MEDIUM,
|
||
"high": types.MediaResolution.MEDIA_RESOLUTION_HIGH,
|
||
}[res]
|
||
|
||
# Thinking is left at the server default deliberately. Forcing the budget
|
||
# to zero was measured on gemini-3.1-flash-live over interleaved trials
|
||
# and did not make the first word arrive sooner — this model does not
|
||
# appear to deliberate on the Live path, so pinning the field only adds a
|
||
# way for a future release to behave differently. Set "thinking_enabled"
|
||
# in config/api_keys.json to true to let it reason instead.
|
||
if get_thinking_enabled():
|
||
out["thinking_config"] = types.ThinkingConfig(thinking_budget=-1)
|
||
|
||
return out
|
||
|
||
async def _execute_tool(self, fc) -> types.FunctionResponse:
|
||
name = fc.name
|
||
args = dict(fc.args or {})
|
||
|
||
print(f"[JARVIS] 🔧 {name} {args}")
|
||
self.ui.set_state("THINKING")
|
||
|
||
|
||
if name == "save_memory":
|
||
category = args.get("category", "notes")
|
||
key = args.get("key", "")
|
||
value = args.get("value", "")
|
||
if key and value:
|
||
update_memory({category: {key: {"value": value}}})
|
||
print(f"[Memory] 💾 save_memory: {category}/{key} = {value}")
|
||
if not self.ui.muted:
|
||
self.ui.set_state("LISTENING")
|
||
return types.FunctionResponse(
|
||
id=fc.id, name=name,
|
||
response={"result": "ok", "silent": True}
|
||
)
|
||
|
||
loop = asyncio.get_event_loop()
|
||
result = "Done."
|
||
|
||
try:
|
||
if name == "recall_memory":
|
||
# Local file search: no network, no second model. Kept out of
|
||
# the executor deliberately — it is a dictionary scan over a few
|
||
# hundred short strings, and a thread hop would cost more than
|
||
# the work itself.
|
||
result = search_memory(args.get("query", ""), limit=8)
|
||
|
||
elif name == "undo":
|
||
if str(args.get("action", "")).lower().strip() == "list":
|
||
items = undo_stack.history()
|
||
result = ("Things I can undo, most recent first:\n"
|
||
+ "\n".join(f"{i+1}. {t}" for i, t in enumerate(items))
|
||
) if items else "I have not changed anything I can undo yet."
|
||
else:
|
||
result = await loop.run_in_executor(None, undo_stack.undo_last)
|
||
|
||
elif name == "screen_process":
|
||
import time as _t_mod
|
||
_now = _t_mod.monotonic()
|
||
_cooldown = 4.0 # seconds — covers echo window after speaking ends
|
||
if self._vision_busy or (_now - self._vision_last_time) < _cooldown:
|
||
_wait = max(0, _cooldown - (_now - self._vision_last_time))
|
||
print(f"[Vision] ⏳ Cooldown active ({_wait:.1f}s remaining) — ignoring duplicate call")
|
||
result = "Vision is still processing the previous request. I will not call this again."
|
||
else:
|
||
self._vision_busy = True
|
||
self._vision_last_time = _now
|
||
angle = args.get("angle", "screen").lower()
|
||
user_text = args.get("text", "What do you see?")
|
||
if angle == "camera":
|
||
img_b, mime_t = await loop.run_in_executor(None, _capture_camera)
|
||
self.ui.start_camera_stream()
|
||
self._vision_cam_active = True
|
||
print(f"[Vision] 📷 Camera: {len(img_b):,} bytes")
|
||
_stall = "camera"
|
||
else:
|
||
img_b, mime_t = await loop.run_in_executor(None, _capture_screen)
|
||
print(f"[Vision] 🖥️ Screen: {len(img_b):,} bytes")
|
||
_stall = "screen"
|
||
self._pending_vision = (img_b, mime_t, user_text, angle)
|
||
# The image is attached to this same exchange, so there is
|
||
# nothing to stall for and nothing to announce. Asking for an
|
||
# acknowledgement here is what produced two spoken answers —
|
||
# the model filled that turn by answering the question from
|
||
# imagination, then answered it again once it could see.
|
||
result = (
|
||
f"[VISION_ACTIVE] {_stall.capitalize()} captured and attached to this "
|
||
f"same exchange. Do not acknowledge and do not answer yet — the image "
|
||
f"is arriving with this result. Reply once, from what you actually see "
|
||
f"in it."
|
||
)
|
||
|
||
elif name == "close_camera":
|
||
self.ui.stop_camera_stream()
|
||
result = "Camera closed."
|
||
|
||
elif name == "system_status":
|
||
r = await loop.run_in_executor(None, get_system_status)
|
||
result = str(r)
|
||
|
||
elif name == "manage_monitor":
|
||
action = args.get("action", "").lower().strip()
|
||
topic = args.get("topic", "").strip()
|
||
if action == "add" and topic:
|
||
result = await asyncio.to_thread(add_monitor, topic)
|
||
elif action == "remove" and topic:
|
||
result = await asyncio.to_thread(remove_monitor, topic)
|
||
elif action == "list":
|
||
topics = await asyncio.to_thread(list_monitors)
|
||
result = ("Monitoring: " + ", ".join(topics)) if topics else "No topics are being monitored."
|
||
else:
|
||
result = "Specify action (add/remove/list) and a topic."
|
||
|
||
elif name == "shutdown_jarvis":
|
||
self.ui.write_log("SYS: Shutdown requested.")
|
||
async def _do_shutdown():
|
||
await self._save_session_summary()
|
||
if self.session:
|
||
try:
|
||
await self.session.send_client_content(
|
||
turns={"role": "user", "parts": [{"text": "Say a brief natural goodbye to the user."}]},
|
||
turn_complete=True,
|
||
)
|
||
except Exception:
|
||
pass
|
||
await asyncio.sleep(1.5)
|
||
import os as _os
|
||
_os._exit(0)
|
||
asyncio.create_task(_do_shutdown())
|
||
|
||
elif self._action_registry.has(name):
|
||
# file_processor: fall back to the currently-uploaded file when none is given
|
||
if name == "file_processor" and not args.get("file_path") and self.ui.current_file:
|
||
args["file_path"] = self.ui.current_file
|
||
_ctx = {"player": self.ui, "speak": self.speak,
|
||
"response": None, "session_memory": None}
|
||
r = await loop.run_in_executor(None, lambda: self._action_registry.run(name, args, _ctx))
|
||
result = r or "Done."
|
||
# web_search: mirror results to the on-screen content panel
|
||
if (name == "web_search" and r
|
||
and not r.startswith("No results")
|
||
and not r.startswith("Search failed")):
|
||
_mode = args.get("mode", "search")
|
||
_query = args.get("query") or ", ".join(args.get("items", []))
|
||
_label = f"{_mode.upper()} — {_query[:38]}" if _query else _mode.upper()
|
||
self.ui.show_content(_label, r)
|
||
|
||
else:
|
||
if self._plugin_registry.has(name):
|
||
r = await loop.run_in_executor(
|
||
None,
|
||
lambda: self._plugin_registry.run(name, args, player=self.ui, session_memory=None)
|
||
)
|
||
result = r or "Done."
|
||
else:
|
||
result = f"Unknown tool: {name}"
|
||
|
||
except Exception as e:
|
||
result = f"Tool '{name}' failed: {e}"
|
||
traceback.print_exc()
|
||
self.speak_error(name, e)
|
||
|
||
if not self.ui.muted:
|
||
self.ui.set_state("LISTENING")
|
||
|
||
print(f"[JARVIS] 📤 {name} → {str(result)[:80]}")
|
||
|
||
# A tool that declared itself NON_BLOCKING also says when its answer may
|
||
# re-enter the conversation. Without this the model finishes whatever it
|
||
# was saying and then reads the result out on top of it — which, for
|
||
# something like a phone call already ringing, is exactly the noise the
|
||
# non-blocking call was meant to avoid. Tools that declared nothing get
|
||
# the API default and behave as they always have.
|
||
_sched = (self._action_registry.scheduling(name)
|
||
or self._plugin_registry.scheduling(name))
|
||
_extra = {"scheduling": _sched} if _sched else {}
|
||
return types.FunctionResponse(
|
||
id=fc.id, name=name,
|
||
response={"result": result},
|
||
**_extra
|
||
)
|
||
|
||
async def _send_realtime(self):
|
||
while True:
|
||
msg = await self.out_queue.get()
|
||
# Gemini 3.x Live rejects the old realtime_input.media_chunks field
|
||
# (what `media=...` maps to) and closes the socket with a 1007. Send
|
||
# mic / phone PCM through the new `audio` field instead. Queue items
|
||
# are {"data": <bytes>, "mime_type": <str>} from _listen_audio and
|
||
# the phone relay.
|
||
await self.session.send_realtime_input(
|
||
audio=types.Blob(
|
||
data=msg["data"],
|
||
mime_type=msg.get("mime_type", "audio/pcm"),
|
||
)
|
||
)
|
||
|
||
async def _listen_audio(self):
|
||
print("[JARVIS] 🎤 Mic started")
|
||
loop = asyncio.get_event_loop()
|
||
|
||
def callback(indata, frames, time_info, status):
|
||
# ── Wake-word gate ───────────────────────────────────────────────
|
||
# While asleep, the mic audio NEVER goes to Gemini (nothing is
|
||
# streamed, so JARVIS can't respond to speech not addressed to it and
|
||
# nothing leaves the machine). Frames are instead handed to the local
|
||
# detector, which runs its model in ITS OWN thread — the cost here is
|
||
# only a queue push, so the audio path is never slowed. When wake word
|
||
# is off (default) or we're awake, this is a single boolean check.
|
||
if self._wake_enabled and not self._awake:
|
||
det = self._wake_detector
|
||
if det is not None:
|
||
det.feed(indata)
|
||
return
|
||
with self._speaking_lock:
|
||
jarvis_speaking = self._is_speaking
|
||
|
||
# ── Barge-in ─────────────────────────────────────────────────────
|
||
# While JARVIS talks the mic is not streamed, but it is still worth
|
||
# listening to locally: if the user starts speaking, cut the answer
|
||
# short the way a person would stop when interrupted.
|
||
#
|
||
# The whole difficulty is echo — on speakers the mic hears JARVIS.
|
||
# So the test is not "is the mic loud" but "is the mic louder than
|
||
# the echo of what we are playing right now", sustained long enough
|
||
# that a cough or a keystroke cannot trigger it.
|
||
if jarvis_speaking:
|
||
# Nothing is streamed while JARVIS talks.
|
||
#
|
||
# Interrupting by voice used to live here: `EchoGuard` can pick a
|
||
# user out from under our own echo, and `core/echo.py` still does
|
||
# that for the tail below. Re-enabling is small — classify each
|
||
# block here and call interrupt() after `required_blocks` of
|
||
# agreement — but it depends on the listener's room, so it stays
|
||
# out until it can be tried on real hardware.
|
||
return
|
||
|
||
# ── Echo tail ────────────────────────────────────────────────────
|
||
# The speaking flag has dropped but the speakers have not finished.
|
||
# Sending this to the model is how an assistant hears itself, decides
|
||
# it was addressed, and answers its own last sentence. The microphone
|
||
# stays OPEN — the guard only drops blocks that are our own voice, so
|
||
# replying the instant it stops still works.
|
||
if self._tail_active():
|
||
try:
|
||
if not self._echo.is_user_speech(
|
||
indata, SEND_SAMPLE_RATE, _pcm_level(indata)):
|
||
return
|
||
self._tail_until = 0.0 # a real voice ends the tail early
|
||
except Exception:
|
||
return
|
||
elif self._echo._hist:
|
||
self._echo.reset()
|
||
|
||
# ── Push-to-talk ─────────────────────────────────────────────────
|
||
# When it is on the microphone is closed by default and the chord
|
||
# opens it, which is the whole point: nothing leaves the machine
|
||
# unless you are holding the key.
|
||
if self._ptt_enabled and not self._ptt_held:
|
||
return
|
||
|
||
if not self.ui.muted and not self._phone_active:
|
||
data = indata.tobytes()
|
||
loop.call_soon_threadsafe(
|
||
self.out_queue.put_nowait,
|
||
{"data": data, "mime_type": "audio/pcm"}
|
||
)
|
||
# Feed the live mic level to the HUD so the waveform reacts to
|
||
# the user's actual voice while listening. Purely cosmetic — any
|
||
# failure here must never disturb the mic.
|
||
try:
|
||
self.ui.set_audio_level(_pcm_level(indata))
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
def _open_mic(dev):
|
||
return sd.InputStream(
|
||
samplerate=SEND_SAMPLE_RATE,
|
||
channels=CHANNELS,
|
||
dtype="int16",
|
||
blocksize=CHUNK_SIZE,
|
||
device=dev,
|
||
callback=callback,
|
||
)
|
||
|
||
# Which microphone. resolve() returns None for "system default" and
|
||
# for a saved device that is no longer present — so a headset
|
||
# unplugged since the last run falls back to the built-in mic
|
||
# instead of raising on startup and taking the session with it.
|
||
_mic_name = get_input_device()
|
||
_mic_dev = audio_devices.resolve(_mic_name, "input")
|
||
if _mic_dev is not None:
|
||
print(f"[JARVIS] 🎤 Input device: {_mic_name}")
|
||
try:
|
||
_mic_stream = _open_mic(_mic_dev)
|
||
except Exception as _e:
|
||
# A device the picker listed but the driver will not open right
|
||
# now — exclusive mode, a webcam already in use, a virtual mic
|
||
# whose source went away. Chosen hardware failing must never
|
||
# mean the assistant cannot hear at all.
|
||
if _mic_dev is None:
|
||
raise
|
||
print(f"[JARVIS] ⚠️ Mic '{_mic_name}' failed: {_e} — using default")
|
||
self.ui.write_log(
|
||
f"SYS: Microphone '{_mic_name}' unavailable — using system default."
|
||
)
|
||
_mic_stream = _open_mic(None)
|
||
|
||
with _mic_stream:
|
||
print("[JARVIS] 🎤 Mic stream open")
|
||
while True:
|
||
await asyncio.sleep(0.1)
|
||
except Exception as e:
|
||
print(f"[JARVIS] ❌ Mic: {e}")
|
||
raise
|
||
|
||
async def _flush_pending_vision(self) -> bool:
|
||
"""Send a captured frame immediately after its tool response.
|
||
|
||
The frame is already in hand by the time `screen_process` returns — the
|
||
capture happened inside the tool call. The old flow still made the model
|
||
speak a turn first and only injected the image on that turn's
|
||
turn_complete, which cost a whole extra round trip AND produced two
|
||
spoken answers: one improvised without the picture, then the real one.
|
||
Sending it here means the model has the tool result and the image before
|
||
it generates anything, so the user gets one answer, sooner.
|
||
"""
|
||
if not (self._pending_vision and self.session):
|
||
return False
|
||
|
||
import base64 as _b64
|
||
img_b, mime_t, question, angle = self._pending_vision
|
||
self._pending_vision = None
|
||
b64 = _b64.b64encode(img_b).decode("ascii")
|
||
print(f"[Vision] 📤 {len(img_b):,} bytes (angle={angle}) → main session")
|
||
|
||
# Label the source. Without it the image arrives carrying nothing but
|
||
# the user's own sentence, and a screenshot of this app — which has a
|
||
# face in the middle of it — got read as a photo of the user. What the
|
||
# label *means* is explained once, in the generated [SELF] block.
|
||
src = ("[IMAGE SOURCE: WEBCAM]" if angle == "camera"
|
||
else "[IMAGE SOURCE: SCREEN CAPTURE]")
|
||
await self.session.send_client_content(
|
||
turns={"role": "user", "parts": [
|
||
{"inline_data": {"mime_type": mime_t, "data": b64}},
|
||
{"text": f"{src}\n\n{question}"},
|
||
]},
|
||
turn_complete=True,
|
||
)
|
||
|
||
if self._vision_cam_active:
|
||
# Camera: stay busy until JARVIS has finished speaking the answer,
|
||
# then close the preview.
|
||
self._vision_cam_active = False
|
||
self._vision_close_pending = True
|
||
else:
|
||
self._vision_busy = False
|
||
return True
|
||
|
||
async def _receive_audio(self):
|
||
print("[JARVIS] 👂 Recv started")
|
||
out_buf, in_buf = [], []
|
||
|
||
try:
|
||
while True:
|
||
async for response in self.session.receive():
|
||
|
||
# ── Session resumption ───────────────────────────────────
|
||
# The server sends this periodically. `resumable` goes false
|
||
# while a turn is mid-flight — replaying a handle from that
|
||
# moment is what the flag exists to prevent — so only
|
||
# resumable handles are kept. This is three lines and it is
|
||
# the entire fix for "every reconnect forgets everything".
|
||
_sru = getattr(response, "session_resumption_update", None)
|
||
if _sru is not None:
|
||
if getattr(_sru, "resumable", False) and getattr(_sru, "new_handle", None):
|
||
if self._resume_handle is None:
|
||
print("[JARVIS] 🔗 Session resumption armed")
|
||
self._resume_handle = _sru.new_handle
|
||
|
||
if response.data:
|
||
if self._interrupted:
|
||
pass # discard: interrupted
|
||
else:
|
||
if self._turn_done_event and self._turn_done_event.is_set():
|
||
self._turn_done_event.clear()
|
||
# Split into ~50 ms chunks so interrupt() stops audio within 50 ms
|
||
# (24000 Hz × 2 bytes/sample × 0.05 s = 2400 bytes per slice)
|
||
_audio_data = response.data
|
||
_SLICE = 2400
|
||
for _i in range(0, len(_audio_data), _SLICE):
|
||
self.audio_in_queue.put_nowait(_audio_data[_i : _i + _SLICE])
|
||
|
||
if response.server_content:
|
||
sc = response.server_content
|
||
|
||
if sc.output_transcription and sc.output_transcription.text:
|
||
txt = _clean_transcript(sc.output_transcription.text)
|
||
# A turn that involves a tool call passes through
|
||
# several turn_completes, and the API re-sends the
|
||
# tail of the transcript across them. Comparing only
|
||
# against the previous chunk missed that — once
|
||
# out_buf had been flushed and emptied, the repeat
|
||
# sailed straight back in, which logged the answer
|
||
# twice AND made the avatar mouth it twice.
|
||
if txt and not _is_repeat_chunk(txt, out_buf):
|
||
out_buf.append(txt)
|
||
# Hand the words to the mouth as they arrive, so
|
||
# the avatar can form the consonants the audio
|
||
# alone cannot show. Pure string work — it adds
|
||
# nothing measurable to the response path.
|
||
self._visemes.feed_text(txt)
|
||
|
||
if sc.input_transcription and sc.input_transcription.text:
|
||
txt = _clean_transcript(sc.input_transcription.text)
|
||
if txt:
|
||
in_buf.append(txt)
|
||
self._last_user_speech = time.monotonic()
|
||
|
||
if sc.turn_complete:
|
||
if self._turn_done_event:
|
||
self._turn_done_event.set()
|
||
|
||
# If this turn_complete ends an interrupted response, clear the
|
||
# flag and skip all further processing for that turn.
|
||
if self._interrupted:
|
||
self._interrupted = False
|
||
in_buf = []
|
||
out_buf = []
|
||
self._visemes.reset()
|
||
continue
|
||
|
||
full_in = " ".join(in_buf).strip()
|
||
if full_in:
|
||
self._last_out_logged = "" # new exchange
|
||
self.ui.write_log(f"You: {full_in}")
|
||
self._session_log.append(f"User: {full_in}")
|
||
if self._dashboard:
|
||
asyncio.create_task(self._dashboard.broadcast({
|
||
"type": "log", "speaker": "user",
|
||
"text": full_in,
|
||
"ts": datetime.now().isoformat(),
|
||
}))
|
||
in_buf = []
|
||
|
||
full_out = " ".join(out_buf).strip()
|
||
# Second line of defence: even if a repeat slips
|
||
# into a *fresh* buffer after a flush, never log the
|
||
# same answer (or a tail of it) twice in a row.
|
||
if full_out and len(full_out) >= _REPEAT_MIN and self._last_out_logged:
|
||
if full_out in self._last_out_logged:
|
||
full_out = ""
|
||
if full_out:
|
||
self._last_out_logged = full_out
|
||
self.ui.write_log(f"{self._asst_name}: {full_out}")
|
||
self._session_log.append(f"{self._asst_name}: {full_out}")
|
||
if self._dashboard:
|
||
asyncio.create_task(self._dashboard.broadcast({
|
||
"type": "log", "speaker": "jarvis",
|
||
"text": full_out,
|
||
"ts": datetime.now().isoformat(),
|
||
}))
|
||
out_buf = []
|
||
|
||
if self._vision_close_pending:
|
||
# This turn_complete IS the vision answer — close camera + release busy flag
|
||
self._vision_close_pending = False
|
||
self._vision_busy = False
|
||
async def _cam_close():
|
||
await asyncio.sleep(2.0)
|
||
self.ui.stop_camera_stream()
|
||
asyncio.create_task(_cam_close())
|
||
|
||
if response.tool_call:
|
||
fn_responses = []
|
||
for fc in response.tool_call.function_calls:
|
||
print(f"[JARVIS] 📞 {fc.name}")
|
||
fr = await self._execute_tool(fc)
|
||
fn_responses.append(fr)
|
||
await self.session.send_tool_response(
|
||
function_responses=fn_responses
|
||
)
|
||
await self._flush_pending_vision()
|
||
except Exception as e:
|
||
print(f"[JARVIS] ❌ Recv: {e}")
|
||
traceback.print_exc()
|
||
raise
|
||
|
||
async def _play_audio(self):
|
||
print("[JARVIS] 🔊 Play started")
|
||
|
||
_spk_name = get_output_device()
|
||
_spk_dev = audio_devices.resolve(_spk_name, "output")
|
||
if _spk_dev is not None:
|
||
print(f"[JARVIS] 🔊 Output device: {_spk_name}")
|
||
|
||
def _open_spk(dev):
|
||
st = sd.RawOutputStream(
|
||
samplerate=RECEIVE_SAMPLE_RATE,
|
||
channels=CHANNELS,
|
||
dtype="int16",
|
||
blocksize=CHUNK_SIZE,
|
||
device=dev,
|
||
)
|
||
st.start()
|
||
return st
|
||
|
||
try:
|
||
stream = _open_spk(_spk_dev)
|
||
except Exception as _e:
|
||
# A chosen output that the host API accepts by name but refuses to
|
||
# open (exclusive mode, wrong sample rate, device asleep) must not
|
||
# cost the user their voice. Fall back to the default and say so.
|
||
if _spk_dev is None:
|
||
raise
|
||
print(f"[JARVIS] ⚠️ Output device '{_spk_name}' failed: {_e} — using default")
|
||
self.ui.write_log(f"SYS: Speaker '{_spk_name}' unavailable — using system default.")
|
||
stream = _open_spk(None)
|
||
|
||
# Ask the device how far behind the speakers actually are, rather than
|
||
# assuming. This is what the echo tail is sized from, so a machine with a
|
||
# large audio buffer gets a correspondingly longer guard — and one with a
|
||
# tiny buffer is not penalised with a delay it does not need.
|
||
try:
|
||
lat = float(getattr(stream, "latency", 0.0) or 0.0)
|
||
if 0.0 < lat < 1.0:
|
||
self._out_latency = lat
|
||
print(f"[JARVIS] 🔊 Output latency {self._out_latency*1000:.0f} ms "
|
||
f"→ echo tail {(self._out_latency + _TAIL_MARGIN)*1000:.0f} ms")
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
while True:
|
||
try:
|
||
chunk = await asyncio.wait_for(
|
||
self.audio_in_queue.get(),
|
||
timeout=0.1
|
||
)
|
||
except asyncio.TimeoutError:
|
||
if (
|
||
self._turn_done_event
|
||
and self._turn_done_event.is_set()
|
||
and self.audio_in_queue.empty()
|
||
):
|
||
self.set_speaking(False)
|
||
self._turn_done_event.clear()
|
||
continue
|
||
|
||
self.set_speaking(True)
|
||
|
||
# Batch all immediately-available chunks into one write to reduce
|
||
# thread-pool round-trips (was one asyncio.to_thread per 50ms slice).
|
||
# Cap at ~200 ms so interrupt() still stops audio within ~200 ms.
|
||
batch = bytearray(chunk)
|
||
while len(batch) < 9600: # 9600 bytes ≈ 200 ms at 24 kHz / 16-bit mono
|
||
try:
|
||
batch.extend(self.audio_in_queue.get_nowait())
|
||
except asyncio.QueueEmpty:
|
||
break
|
||
|
||
# Drive the HUD waveform and the avatar's mouth from JARVIS's
|
||
# own voice. The batch is up to 200 ms long, so we hand over a
|
||
# *schedule* of 20 ms viseme frames instead of a single averaged
|
||
# level and let the HUD play it out in step with the audio.
|
||
try:
|
||
pcm = np.frombuffer(bytes(batch), dtype=np.int16)
|
||
hop = _VIS_HOP / RECEIVE_SAMPLE_RATE
|
||
frames = _pcm_visemes(pcm, sr=RECEIVE_SAMPLE_RATE)
|
||
# When does this batch become audible? The stream was
|
||
# started at launch and its callback has been pulling
|
||
# silence ever since, so the first bytes of a reply reach
|
||
# the speaker about one callback period later — NOT one
|
||
# buffer later. `stream.latency` reports the buffer's
|
||
# capacity, which is how much can be queued ahead, and on
|
||
# Windows that is commonly 300-500 ms. Anchoring on it put
|
||
# the entire schedule a buffer late; that is the half second
|
||
# of lag, and it grew with whatever the device reported.
|
||
#
|
||
# After the anchor nothing needs measuring: the device
|
||
# consumes at exactly realtime, so each batch sounds one
|
||
# batch-duration after the one before it. The cursor is
|
||
# re-anchored only when it leaves the range physically
|
||
# possible — behind `now` means the device drained and this
|
||
# batch starts a fresh stretch of speech, while further
|
||
# ahead than the buffer can hold means it has drifted.
|
||
now = time.time()
|
||
horizon = self._out_latency + _CURSOR_SLACK
|
||
if not (now <= self._play_cursor <= now + horizon):
|
||
self._play_cursor = now + _FIRST_SOUND
|
||
at = self._play_cursor
|
||
# Advance by the batch's own duration whether or not it
|
||
# yielded frames, so a block too short to analyse cannot
|
||
# shift everything after it out of step with the audio.
|
||
self._play_cursor += pcm.size / RECEIVE_SAMPLE_RATE
|
||
if frames:
|
||
frames = self._visemes.frames(frames, hop)
|
||
self.ui.push_visemes(frames, hop, at)
|
||
# Barge-in needs to know what we are playing, not just
|
||
# how loud: the guard subtracts this from the microphone.
|
||
self._out_level = max(f[0] for f in frames)
|
||
self._echo.note_output(pcm, RECEIVE_SAMPLE_RATE,
|
||
self._out_level)
|
||
else:
|
||
lvl = _pcm_level(pcm)
|
||
self.ui.set_audio_level(lvl)
|
||
self._out_level = lvl
|
||
self._echo.note_output(pcm, RECEIVE_SAMPLE_RATE, lvl)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
await asyncio.to_thread(stream.write, bytes(batch))
|
||
except (RuntimeError, asyncio.CancelledError):
|
||
break # executor shutting down — exit cleanly
|
||
except Exception as e:
|
||
print(f"[JARVIS] ❌ Play: {e}")
|
||
raise
|
||
finally:
|
||
self.set_speaking(False)
|
||
stream.stop()
|
||
stream.close()
|
||
|
||
# ── Morning briefing ────────────────────────────────────────────────────────
|
||
|
||
async def _send_startup_briefing(self) -> None:
|
||
"""
|
||
Two-phase briefing optimized for speed:
|
||
Phase 1 — instant greeting (no tools) → speech starts in <1s
|
||
Phase 2 — news pre-fetched in a background thread while Phase 1 plays,
|
||
delivered as ready text (no Gemini tool-call round-trip) and
|
||
shown on the UI content panel. Waits for turn_complete event
|
||
instead of a fixed sleep so there is no unnecessary gap.
|
||
"""
|
||
memory = load_memory()
|
||
identity = memory.get("identity", {})
|
||
|
||
def _val(k: str) -> str:
|
||
e = identity.get(k, {})
|
||
return (e.get("value", "") if isinstance(e, dict) else str(e)).strip()
|
||
|
||
lang = _val("language")
|
||
name = _val("name")
|
||
time_str = datetime.now().strftime("%H:%M")
|
||
|
||
# Start fetching news immediately — runs in parallel while phase 1 plays
|
||
loop = asyncio.get_event_loop()
|
||
news_future = loop.run_in_executor(None, _fetch_news_sync, "top world news today")
|
||
|
||
await asyncio.sleep(0.3)
|
||
if not self.session:
|
||
return
|
||
|
||
# ── Phase 1: instant greeting ─────────────────────────────────────────
|
||
# The briefing fires before the user has said anything, so the
|
||
# remembered language is the only signal there is. It is a starting
|
||
# point, not a setting: the moment they reply, their language wins.
|
||
lang_clause = (f" Speak this greeting in {lang}, then follow the "
|
||
f"user's own language from their first reply onward."
|
||
if lang else "")
|
||
name_clause = f" Address the user as {name}." if name else ""
|
||
|
||
# Inject last session context if available — pop removes it so it's never repeated
|
||
last = await asyncio.to_thread(pop_last_session)
|
||
session_clause = ""
|
||
if last:
|
||
try:
|
||
_delta = (datetime.now() - datetime.strptime(last["date"], "%Y-%m-%d")).days
|
||
_when = "earlier today" if _delta == 0 else ("yesterday" if _delta == 1 else f"{_delta} days ago")
|
||
except Exception:
|
||
_when = "last time"
|
||
session_clause = (
|
||
f" Also briefly and naturally mention that {_when}: {last['summary']}"
|
||
)
|
||
|
||
p1 = (
|
||
f"Greet the user warmly, mention it is {time_str}, and say you are fetching today's news now.{session_clause} "
|
||
f"Keep it to 2 short sentences max. Do not call any tools.{lang_clause}{name_clause}"
|
||
)
|
||
|
||
# Clear the turn-done event so we can wait for Phase 1 to finish
|
||
if self._turn_done_event:
|
||
self._turn_done_event.clear()
|
||
|
||
await self.session.send_client_content(
|
||
turns={"role": "user", "parts": [{"text": p1}]},
|
||
turn_complete=True,
|
||
)
|
||
print("[JARVIS] Briefing phase 1 (greeting) sent.")
|
||
|
||
# ── Phase 2: fire as soon as Phase 1 audio is done ───────────────────
|
||
async def _deliver_news():
|
||
try:
|
||
lang_str = (f" Speak in {lang} unless the user has since "
|
||
f"spoken another language, in which case use theirs."
|
||
if lang else "")
|
||
|
||
# Wait for news fetch (already running) and Phase 1 turn-complete
|
||
# in parallel — whichever takes longer determines the wait time
|
||
news_done = asyncio.wrap_future(news_future)
|
||
turn_waited = False
|
||
if self._turn_done_event:
|
||
try:
|
||
await asyncio.wait_for(self._turn_done_event.wait(), timeout=6.0)
|
||
turn_waited = True
|
||
except asyncio.TimeoutError:
|
||
pass
|
||
|
||
# Extra buffer: turn_complete fires when Gemini finishes *generating*
|
||
# Phase 1, but audio may still be playing. Waiting a beat here
|
||
# prevents Phase 2 audio from arriving while Phase 1 is mid-sentence
|
||
# (which sounds like a "repeated first response" to the user).
|
||
if turn_waited:
|
||
await asyncio.sleep(0.8)
|
||
else:
|
||
await asyncio.sleep(1.0)
|
||
|
||
try:
|
||
news_text = await asyncio.wait_for(news_done, timeout=8.0)
|
||
except Exception as e:
|
||
self.ui.write_log(f"SYS: News fetch timed out/failed: {e!r}")
|
||
news_text = ""
|
||
|
||
if not self.session:
|
||
return
|
||
|
||
failed = (not news_text) or news_text.startswith(
|
||
("No news found", "Search failed", "Please provide")
|
||
)
|
||
if not failed:
|
||
# Show on UI content panel immediately
|
||
self.ui.show_content("NEWS — top world news today", news_text)
|
||
|
||
p2 = (
|
||
f"[BRIEFING] Here are today's top news headlines:\n{news_text}\n\n"
|
||
"Pick ONE headline, summarise it in one sentence, then say the full list "
|
||
f"is displayed on screen. Do not call any tools.{lang_str}"
|
||
)
|
||
else:
|
||
self.ui.write_log(
|
||
f"SYS: News unavailable — backend returned: {news_text[:120]!r}"
|
||
)
|
||
p2 = (
|
||
"News headlines could not be fetched right now. "
|
||
f"Let the user know briefly.{lang_str}"
|
||
)
|
||
|
||
await self.session.send_client_content(
|
||
turns={"role": "user", "parts": [{"text": p2}]},
|
||
turn_complete=True,
|
||
)
|
||
print("[JARVIS] Briefing phase 2 (news) sent.")
|
||
except Exception as e:
|
||
print(f"[Briefing] Phase 2 error: {e}")
|
||
print(f"[JARVIS] Briefing phase 2 failed: {e}")
|
||
self.ui.write_log("SYS: Could not fetch the news for the briefing.")
|
||
|
||
asyncio.create_task(_deliver_news())
|
||
|
||
# ── Session memory ──────────────────────────────────────────────────────────
|
||
|
||
async def _save_session_summary(self) -> None:
|
||
"""Summarise the current session in 1-2 sentences and save to long_term.json."""
|
||
log = self._session_log
|
||
if len(log) < 3: # need at least one exchange to be worth saving
|
||
return
|
||
self._session_log = [] # reset immediately so the next session starts clean
|
||
|
||
memory = load_memory()
|
||
lang_entry = memory.get("identity", {}).get("language", {})
|
||
lang = (lang_entry.get("value", "") if isinstance(lang_entry, dict) else str(lang_entry)).strip()
|
||
lang = lang or "English"
|
||
|
||
convo = "\n".join(log[-40:]) # cap at last 40 turns to stay within token budget
|
||
prompt = (
|
||
f"Summarize this conversation in 1-2 sentences in {lang}. "
|
||
"Focus on what the user accomplished or discussed. "
|
||
"Output ONLY the summary text, nothing else:\n\n" + convo
|
||
)
|
||
try:
|
||
from core import gemini
|
||
summary = await asyncio.to_thread(
|
||
gemini.text, prompt, gemini.SMART, None, 30_000,
|
||
)
|
||
if summary:
|
||
save_session_summary(summary, lang)
|
||
except Exception as e:
|
||
print(f"[Memory] ⚠️ Session summary failed: {e}")
|
||
|
||
# ── System monitor ──────────────────────────────────────────────────────────
|
||
|
||
async def _run_system_monitor(self) -> None:
|
||
"""Background task: voice alerts when metrics exceed thresholds."""
|
||
while True:
|
||
await asyncio.sleep(10)
|
||
alert = await asyncio.to_thread(self._sys_monitor.check)
|
||
if not alert or not self.session or not self._awake:
|
||
continue
|
||
# Don't interrupt an active conversation
|
||
with self._speaking_lock:
|
||
speaking = self._is_speaking
|
||
if speaking or (time.monotonic() - self._last_user_speech) < 10:
|
||
continue
|
||
try:
|
||
await self.session.send_client_content(
|
||
turns={"role": "user", "parts": [{"text": alert}]},
|
||
turn_complete=True,
|
||
)
|
||
except Exception as e:
|
||
print(f"[Monitor] ⚠️ Could not send alert: {e}")
|
||
|
||
# ── Background monitor ──────────────────────────────────────────────────────
|
||
|
||
async def _run_background_monitor(self) -> None:
|
||
"""Check user-configured topics once per day; speak alerts when new headlines appear."""
|
||
await asyncio.sleep(300) # wait 5 min after startup before first check
|
||
while True:
|
||
if self.session and self._awake:
|
||
# Don't interrupt if user spoke recently or JARVIS is mid-sentence
|
||
with self._speaking_lock:
|
||
speaking = self._is_speaking
|
||
recent_speech = (time.monotonic() - self._last_user_speech) < 30
|
||
if not speaking and not recent_speech:
|
||
try:
|
||
alerts = await asyncio.to_thread(monitor_check_all)
|
||
memory = load_memory()
|
||
lang_e = memory.get("identity", {}).get("language", {})
|
||
lang = (lang_e.get("value", "") if isinstance(lang_e, dict) else str(lang_e)).strip() or "English"
|
||
for alert in alerts:
|
||
msg = (
|
||
f"{alert}\n\n"
|
||
f"Inform the user about this development naturally in {lang}. "
|
||
"One brief sentence only."
|
||
)
|
||
await self.session.send_client_content(
|
||
turns={"role": "user", "parts": [{"text": msg}]},
|
||
turn_complete=True,
|
||
)
|
||
print("[JARVIS] Monitor alert sent.")
|
||
await asyncio.sleep(6) # gap between consecutive alerts
|
||
except Exception as e:
|
||
print(f"[Monitor] ⚠️ Background check error: {e}")
|
||
await asyncio.sleep(1800) # check every 30 minutes
|
||
|
||
# ── Proactive mode ──────────────────────────────────────────────────────────
|
||
|
||
async def _run_proactive_mode(self) -> None:
|
||
"""
|
||
Background task: periodically checks if the user has been silent long enough,
|
||
then hands time + memory context to Gemini so it can decide what (if anything)
|
||
to say proactively. No hardcoded rules — Gemini makes the call.
|
||
"""
|
||
while True:
|
||
await asyncio.sleep(60) # evaluate once per minute
|
||
|
||
if not self.session or not self._awake:
|
||
continue
|
||
|
||
with self._speaking_lock:
|
||
speaking = self._is_speaking
|
||
if speaking:
|
||
continue
|
||
|
||
if not self._proactive.should_trigger(self._last_user_speech):
|
||
continue
|
||
|
||
self._proactive.mark_triggered()
|
||
|
||
try:
|
||
memory = await asyncio.to_thread(load_memory)
|
||
monitors = await asyncio.to_thread(list_monitors)
|
||
recent_turns = self._session_log[-8:] if self._session_log else []
|
||
prompt = self._proactive.build_prompt(
|
||
memory = memory,
|
||
monitors = monitors or None,
|
||
recent_turns = recent_turns or None,
|
||
)
|
||
await self.session.send_client_content(
|
||
turns={"role": "user", "parts": [{"text": prompt}]},
|
||
turn_complete=True,
|
||
)
|
||
print("[JARVIS] Proactive check-in.")
|
||
except Exception as e:
|
||
print(f"[Proactive] ⚠️ {e}")
|
||
|
||
# ── Phone audio relay ────────────────────────────────────────────────────────
|
||
|
||
async def _relay_phone_audio(self) -> None:
|
||
"""Forward phone mic PCM chunks from dashboard queue into the Gemini Live session."""
|
||
q = self._dashboard._phone_audio_queue
|
||
while True:
|
||
try:
|
||
chunk = await asyncio.wait_for(q.get(), timeout=1.0)
|
||
except asyncio.TimeoutError:
|
||
# No audio for 1 s → phone mic inactive, give PC mic back
|
||
self._phone_active = False
|
||
continue
|
||
self._phone_active = True # phone is streaming — silence PC mic
|
||
with self._speaking_lock:
|
||
speaking = self._is_speaking
|
||
if not speaking and not self.ui.muted:
|
||
try:
|
||
self.out_queue.put_nowait(chunk)
|
||
except asyncio.QueueFull:
|
||
pass
|
||
|
||
def _on_phone_connected(self) -> None:
|
||
self.ui.write_log("SYS: Phone connected via Remote Dashboard.")
|
||
self.ui.notify_phone_connected()
|
||
|
||
# ── dashboard command relay ─────────────────────────────────────────────
|
||
|
||
async def _process_dashboard_commands(self) -> None:
|
||
while True:
|
||
try:
|
||
text = await asyncio.wait_for(
|
||
self._dashboard._command_queue.get(), timeout=0.5
|
||
)
|
||
if not text:
|
||
continue
|
||
# Wait up to 8s for session to become ready after a wake
|
||
for _ in range(80):
|
||
if self.session:
|
||
break
|
||
await asyncio.sleep(0.1)
|
||
if self.session:
|
||
# A remote command is deliberate control and the phone user
|
||
# has no desktop WAKE button — so it wakes JARVIS if asleep.
|
||
if self._wake_enabled and not self._awake:
|
||
self.wake(reason="remote command")
|
||
await self.session.send_client_content(
|
||
turns={"role": "user", "parts": [{"text": text}]},
|
||
turn_complete=True,
|
||
)
|
||
self.ui.write_log(f"[Web]: {text}")
|
||
else:
|
||
print(f"[Dashboard] Dropped command (no session): {text}")
|
||
except asyncio.TimeoutError:
|
||
pass
|
||
except Exception as e:
|
||
print(f"[Dashboard] Command error: {e}")
|
||
await asyncio.sleep(0.5)
|
||
|
||
# ── main loop ───────────────────────────────────────────────────────────
|
||
|
||
async def run(self):
|
||
self._loop = asyncio.get_event_loop()
|
||
self._reconnect_event = asyncio.Event()
|
||
|
||
# ── Wire the shared core services to the interface ───────────────────
|
||
# The confirmation gate is useless without a way to ask, and a memory
|
||
# trim is invisible without a way to say so. Both are bound once here
|
||
# rather than passed down through every action signature.
|
||
confirm_gate.bind(
|
||
show = self.ui.show_confirm,
|
||
hide = self.ui.hide_confirm,
|
||
log = self.ui.write_log,
|
||
)
|
||
set_trim_notifier(self.ui.write_log)
|
||
|
||
# Tell the device picker the exact rates the streams open at, from the
|
||
# constants that actually open them — so it can never list a device that
|
||
# cannot be opened at them.
|
||
audio_devices.configure(SEND_SAMPLE_RATE, RECEIVE_SAMPLE_RATE)
|
||
|
||
# Enumerate audio devices off-thread. The settings drawer must never pay
|
||
# for host-API enumeration on the Qt thread.
|
||
audio_devices.prefetch()
|
||
|
||
# Start dashboard (optional — needs: pip install fastapi "uvicorn[standard]" cryptography)
|
||
try:
|
||
from dashboard.server import DashboardServer
|
||
self._dashboard = DashboardServer()
|
||
self._dashboard.set_connect_callback(self._on_phone_connected)
|
||
asyncio.create_task(self._dashboard.serve())
|
||
# Runs for the whole lifetime, not just inside an active session
|
||
asyncio.create_task(self._process_dashboard_commands())
|
||
except Exception as e:
|
||
print(f"[Dashboard] Disabled: {e}")
|
||
self._dashboard = None
|
||
|
||
while True:
|
||
try:
|
||
print("[JARVIS] Connecting...")
|
||
self.ui.set_state("THINKING")
|
||
_resumed_with = self._resume_handle is not None
|
||
config = self._build_config()
|
||
|
||
# Fresh client on every reconnect — avoids stale HTTP session state
|
||
# v1alpha carries proactive audio; if it gets rejected we fall
|
||
# back to v1beta.
|
||
client = genai.Client(
|
||
api_key=_get_api_key(),
|
||
http_options={"api_version": "v1alpha" if self._enhanced_live else "v1beta"}
|
||
)
|
||
|
||
async with (
|
||
client.aio.live.connect(model=LIVE_MODEL, config=config) as session,
|
||
asyncio.TaskGroup() as tg,
|
||
):
|
||
self.session = session
|
||
self.audio_in_queue = asyncio.Queue()
|
||
self.out_queue = asyncio.Queue(maxsize=200)
|
||
self._turn_done_event = asyncio.Event()
|
||
|
||
# Reset transient state that must not carry over from a previous session
|
||
self._pending_vision = None
|
||
self._vision_cam_active = False
|
||
self._vision_close_pending = False
|
||
self._vision_busy = False
|
||
self._vision_last_time = 0.0
|
||
self._interrupted = False
|
||
|
||
print("[JARVIS] Connected.")
|
||
if _resumed_with:
|
||
# Say it plainly: the difference between "it reconnected"
|
||
# and "it reconnected and still knows what we were doing"
|
||
# is the whole point, and it is invisible otherwise.
|
||
self.ui.write_log("SYS: Reconnected — conversation restored.")
|
||
|
||
# Wake word: if enabled, come up ASLEEP (mic gated, silent)
|
||
# until the user says "Hey Jarvis" or taps wake in the UI.
|
||
if self._wake_enabled:
|
||
self._ensure_wake_detector()
|
||
self._awake = False
|
||
self.ui.set_state("SLEEPING")
|
||
self.ui.write_log("SYS: JARVIS online — sleeping. Say 'Hey Jarvis' to wake me.")
|
||
else:
|
||
self._awake = True
|
||
self.ui.set_state("LISTENING")
|
||
self.ui.write_log("SYS: JARVIS online.")
|
||
|
||
if self._dashboard:
|
||
await self._dashboard.broadcast({"type": "status", "state": "active"})
|
||
|
||
self._reconnect_event.clear() # ignore requests from before this session
|
||
tg.create_task(self._watch_reconnect())
|
||
tg.create_task(self._send_realtime())
|
||
tg.create_task(self._listen_audio())
|
||
tg.create_task(self._receive_audio())
|
||
tg.create_task(self._play_audio())
|
||
tg.create_task(self._run_system_monitor())
|
||
tg.create_task(self._run_background_monitor())
|
||
tg.create_task(self._run_proactive_mode())
|
||
tg.create_task(self._run_sleep_watch())
|
||
if self._dashboard:
|
||
tg.create_task(self._relay_phone_audio())
|
||
|
||
# Morning briefing — fires once per process launch (if enabled).
|
||
# Skipped in wake-word mode: it comes up asleep, and a briefing
|
||
# would mean talking while "asleep".
|
||
if not self._briefing_sent and get_brief_enabled() and self._awake:
|
||
self._briefing_sent = True
|
||
tg.create_task(self._send_startup_briefing())
|
||
|
||
except KeyboardInterrupt:
|
||
raise
|
||
except SystemExit:
|
||
raise
|
||
except BaseException as e:
|
||
# Catches both Exception and BaseExceptionGroup (Python 3.11+
|
||
# TaskGroup raises BaseExceptionGroup when tasks are cancelled
|
||
# externally, which `except Exception` would miss, letting the
|
||
# exception escape the while-loop and causing asyncio.run() to
|
||
# start shutdown — resulting in "executor after shutdown" errors).
|
||
# Voluntary reconnect (voice change) — not an error. Rebuild the
|
||
# session immediately with no backoff and no scary logs.
|
||
if _is_reconnect_signal(e):
|
||
print("[JARVIS] Voluntary reconnect requested.")
|
||
if not _keep_context_of(e):
|
||
# A deliberate clean slate (voice change) — drop the
|
||
# handle so the next connect really does start empty.
|
||
self._resume_handle = None
|
||
self._conn_backoff = 0
|
||
continue
|
||
|
||
# A resumption handle the server will not accept — expired, or
|
||
# belonging to a session it has since dropped. Without this, the
|
||
# same dead handle would be replayed on every retry and the
|
||
# assistant would never come back at all: the feature meant to
|
||
# survive a reconnect would be the thing preventing one. Drop it
|
||
# once and let the next attempt start clean.
|
||
if _resumed_with and (
|
||
"resum" in str(e).lower()
|
||
or "handle" in str(e).lower()
|
||
or "INVALID_ARGUMENT" in str(e)
|
||
or "NOT_FOUND" in str(e)
|
||
):
|
||
print("[JARVIS] 🔗 Resumption handle rejected — starting a fresh session")
|
||
self.ui.write_log("SYS: Could not restore the conversation — starting fresh.")
|
||
self._resume_handle = None
|
||
self._conn_backoff = 0
|
||
continue
|
||
|
||
err_str = str(e)
|
||
print(f"[JARVIS] Error ({type(e).__name__}): {e}")
|
||
traceback.print_exc()
|
||
|
||
# Turn-taking / media / thinking knobs rejected by the server
|
||
# (preview API drift) — drop them first, because they are the
|
||
# newest fields and the cheapest to lose. Proactive audio is
|
||
# tried again on the next pass if the error persists.
|
||
if self._tuned_live and (
|
||
"INVALID_ARGUMENT" in err_str
|
||
or "Unknown name" in err_str
|
||
or "unexpected keyword" in err_str
|
||
or "realtime_input" in err_str.lower()
|
||
or "media_resolution" in err_str.lower()
|
||
or "thinking" in err_str.lower()
|
||
):
|
||
self._tuned_live = False
|
||
print("[JARVIS] Live tuning rejected — reconnecting without it.")
|
||
continue
|
||
|
||
# Proactive audio rejected by the server (preview API drift) —
|
||
# drop it and reconnect with the plain config.
|
||
if self._enhanced_live and (
|
||
"INVALID_ARGUMENT" in err_str
|
||
or "proactiv" in err_str.lower()
|
||
or "Unknown name" in err_str
|
||
or "unexpected keyword" in err_str
|
||
):
|
||
self._enhanced_live = False
|
||
self.ui.write_log(
|
||
"SYS: Proactive audio unavailable — reconnecting without it."
|
||
)
|
||
continue
|
||
|
||
# Invalid API key — stop hammering the API, prompt re-configuration
|
||
if "API key not valid" in err_str or "1007" in err_str:
|
||
self.ui.write_log("ERR: API key invalid — please re-enter your key.")
|
||
self.ui.set_state("SLEEPING")
|
||
self.ui.prompt_reconfig()
|
||
while not self.ui._win._ready:
|
||
await asyncio.sleep(1)
|
||
print("[JARVIS] New API key saved — reconnecting...")
|
||
_conn_backoff = 3
|
||
continue
|
||
|
||
# Network / timeout errors — log clearly and back off
|
||
is_net_err = any(k in err_str for k in (
|
||
"TimeoutError", "timed out", "getaddrinfo", "CancelledError",
|
||
"ConnectionRefusedError", "OSError", "Cannot connect",
|
||
))
|
||
if is_net_err:
|
||
_conn_backoff = min(getattr(self, "_conn_backoff", 3) * 2, 60)
|
||
self._conn_backoff = _conn_backoff
|
||
self.ui.write_log(
|
||
f"NET: Connection failed — retrying in {_conn_backoff}s. "
|
||
"(a VPN may be required)"
|
||
)
|
||
else:
|
||
self._conn_backoff = 3
|
||
finally:
|
||
self.session = None
|
||
# Only save if there was a real conversation (≥3 turns)
|
||
if len(self._session_log) >= 3:
|
||
asyncio.create_task(self._save_session_summary())
|
||
|
||
self.set_speaking(False)
|
||
self.ui.set_state("SLEEPING")
|
||
|
||
if self._dashboard:
|
||
await self._dashboard.broadcast({"type": "status", "state": "sleeping"})
|
||
|
||
delay = getattr(self, "_conn_backoff", 3)
|
||
print(f"[JARVIS] Reconnecting in {delay}s...")
|
||
await asyncio.sleep(delay)
|
||
|
||
def main():
|
||
ui = JarvisUI("face.png")
|
||
|
||
def runner():
|
||
ui.wait_for_api_key()
|
||
jarvis = JarvisLive(ui)
|
||
try:
|
||
asyncio.run(jarvis.run())
|
||
except KeyboardInterrupt:
|
||
print("\n🔴 Shutting down...")
|
||
|
||
threading.Thread(target=runner, daemon=True).start()
|
||
ui.root.mainloop()
|
||
|
||
if __name__ == "__main__":
|
||
main() |