diff --git a/Jarvis.onnx b/Jarvis.onnx new file mode 100644 index 0000000..f70dfb8 Binary files /dev/null and b/Jarvis.onnx differ diff --git a/Jarvis_en_linux_v4_0_0.ppn b/Jarvis_en_linux_v4_0_0.ppn deleted file mode 100644 index 8b16f92..0000000 Binary files a/Jarvis_en_linux_v4_0_0.ppn and /dev/null differ diff --git a/display.py b/display.py index 8224b46..4e3e839 100644 --- a/display.py +++ b/display.py @@ -23,6 +23,7 @@ import urllib.parse import xml.etree.ElementTree as ET from datetime import datetime + _parser = argparse.ArgumentParser(description='JARVIS display', add_help=True) _parser.add_argument('--fullscreen', '-fs', action='store_true', help='Run fullscreen under X11/Wayland') @@ -30,6 +31,8 @@ _parser.add_argument('--width', type=int, default=1280, help='Screen width (default 1280)') _parser.add_argument('--height', type=int, default=800, help='Screen height (default 800)') +_parser.add_argument('--mic', type=int, default=0, + help='Microphone device index (default 0)') _args, _ = _parser.parse_known_args() import pygame @@ -231,6 +234,77 @@ def _weather_worker(locations: list[tuple[float, float, str]]) -> None: _time.sleep(1800) +# ═══════════════════════════════════════════════════════════════════════════════ +# WAKE WORD +# ═══════════════════════════════════════════════════════════════════════════════ + +_OWW_MODEL = '/home/dfr84/Python/JARVIS/Jarvis.onnx' +_OWW_THRESHOLD = 0.5 +_OWW_CHUNK = 1280 + +# Shared wake state — written by audio thread, read by render thread +_wake: dict = {'active': False, 'detected_at': 0.0} + +def _wake_worker() -> None: + try: + import pyaudio + import numpy as np + from openwakeword.model import Model + except ImportError as e: + print(f'[WAKE] Missing dependency: {e} — wake word disabled') + return + + try: + model = Model(wakeword_model_paths=[_OWW_MODEL]) + + audio = pyaudio.PyAudio() + dev_info = audio.get_device_info_by_index(_args.mic) + n_ch = int(dev_info['maxInputChannels']) + native_hz = int(dev_info['defaultSampleRate']) + target_hz = 16000 + # frames_per_buffer scaled so we always get ~_OWW_CHUNK samples at 16 kHz + buf_frames = int(_OWW_CHUNK * native_hz / target_hz) + + stream = audio.open( + format=pyaudio.paInt16, + channels=n_ch, + rate=native_hz, + input=True, + input_device_index=_args.mic, + frames_per_buffer=buf_frames, + ) + + while True: + data = stream.read(buf_frames, exception_on_overflow=False) + audio_data = np.frombuffer(data, dtype=np.int16) + if n_ch > 1: + audio_data = audio_data.reshape(-1, n_ch)[:, 0] + # resample to 16 kHz + if native_hz != target_hz: + ratio = target_hz / native_hz + new_len = int(len(audio_data) * ratio) + indices = np.round(np.linspace(0, len(audio_data) - 1, new_len)).astype(int) + audio_data = audio_data[indices] + prediction = model.predict(audio_data) + for score in prediction.values(): + if score >= _OWW_THRESHOLD: + _wake['active'] = True + _wake['detected_at'] = _time.time() + break + except Exception as e: + import traceback + print(f'[WAKE] {type(e).__name__}: {e}') + traceback.print_exc() + + +def _lerp_color(c1: tuple, c2: tuple, t: float) -> tuple: + return tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3)) + +_FACE_GREEN = ( 40, 200, 80) +_FACE_BLUE = ( 40, 80, 220) +_WAKE_DURATION = 8.0 # seconds before returning to idle + + # ═══════════════════════════════════════════════════════════════════════════════ # WIREFRAME FACE # ═══════════════════════════════════════════════════════════════════════════════ @@ -240,7 +314,8 @@ def _hw(t: float, rx: float) -> float: return rx * math.sqrt(max(0.0, 1.0 - t * t)) return rx * math.sqrt(max(0.0, 1.0 - t * t)) * (1.0 - 0.30 * t) -def draw_wireframe_face(surface, cx, cy, rx, ry): +def draw_wireframe_face(surface, cx, cy, rx, ry, + col_wire=WIRE_COL, col_eye=WIRE_EYE): N_H, N_V, PTS = 13, 11, 40 def sc(xf, t): @@ -248,15 +323,15 @@ def draw_wireframe_face(surface, cx, cy, rx, ry): bow = int(0.06 * w * (1.0 - t*t) * (1.0 - xf*xf)) return (int(cx + xf*w), int(cy + t*ry) - bow) - def draw(pts, col=WIRE_COL): + def draw(pts, col=col_wire): if len(pts) >= 2: pygame.draw.lines(surface, col, False, pts, 1) t_vals = [-1.0 + 2.0*i/(PTS-1) for i in range(PTS)] draw([sc(-1.0, t) for t in t_vals]) draw([sc( 1.0, t) for t in t_vals]) - pygame.draw.lines(surface, WIRE_COL, False, [sc(-1.0,-1.0), sc(1.0,-1.0)], 1) - pygame.draw.lines(surface, WIRE_COL, False, [sc(-0.6,1.0), sc(0.0,1.02), sc(0.6,1.0)], 1) + pygame.draw.lines(surface, col_wire, False, [sc(-1.0,-1.0), sc(1.0,-1.0)], 1) + pygame.draw.lines(surface, col_wire, False, [sc(-0.6,1.0), sc(0.0,1.02), sc(0.6,1.0)], 1) for i in range(1, N_H): t = -1.0 + 2.0*i/N_H @@ -274,7 +349,7 @@ def draw_wireframe_face(surface, cx, cy, rx, ry): ecy = int(cy + eye_t * ry) pts = [(int(ecx + e_rx*math.cos(2*math.pi*k/28)), int(ecy + e_ry*math.sin(2*math.pi*k/28))) for k in range(29)] - pygame.draw.lines(surface, WIRE_EYE, True, pts, 1) + pygame.draw.lines(surface, col_eye, True, pts, 1) inner = int(eye_w * 0.12) e_btm = int(cy + eye_t*ry) + e_ry @@ -489,6 +564,7 @@ def main() -> None: # ── Background threads ──────────────────────────────────────────────────── threading.Thread(target=_news_worker, daemon=True).start() threading.Thread(target=_stocks_worker, daemon=True).start() + threading.Thread(target=_wake_worker, daemon=True).start() try: resolved = [_geocode(city) for city in _LOCATIONS] @@ -507,6 +583,9 @@ def main() -> None: face_img = pygame.transform.smoothscale( raw, (max(1, int(iw*scale)), max(1, int(ih*scale)))) + # Reusable surface for PNG tinting (fill + BLEND_MULT each frame) + face_tint_surf = pygame.Surface(face_img.get_size()) if face_img else None + pg_clock = pygame.time.Clock() while True: @@ -516,14 +595,38 @@ def main() -> None: if event.type == pygame.KEYDOWN and event.key in (pygame.K_ESCAPE, pygame.K_q): pygame.quit(); return + # ── Wake-word face colour ───────────────────────────────────────────── + if _wake['active']: + elapsed = _time.time() - _wake['detected_at'] + if elapsed >= _WAKE_DURATION: + _wake['active'] = False + face_color = None + else: + t = (math.sin(elapsed * math.pi * 2.0) + 1.0) / 2.0 # 0→1, 1 Hz + face_color = _lerp_color(_FACE_GREEN, _FACE_BLUE, t) + else: + face_color = None + screen.fill(BLACK) - # Face + # Face — tinted when wake active, normal otherwise if face_img: - screen.blit(face_img, face_img.get_rect(center=(face_cx, face_cy)), - special_flags=pygame.BLEND_ADD) + if face_color and face_tint_surf: + face_tint_surf.fill(face_color) + face_tint_surf.blit(face_img, (0, 0), + special_flags=pygame.BLEND_MULT) + screen.blit(face_tint_surf, + face_tint_surf.get_rect(center=(face_cx, face_cy)), + special_flags=pygame.BLEND_ADD) + else: + screen.blit(face_img, + face_img.get_rect(center=(face_cx, face_cy)), + special_flags=pygame.BLEND_ADD) else: - draw_wireframe_face(screen, face_cx, face_cy, face_rx, face_ry) + cw = face_color or WIRE_COL + ce = face_color or WIRE_EYE + draw_wireframe_face(screen, face_cx, face_cy, face_rx, face_ry, + col_wire=cw, col_eye=ce) # Clock (top-left) draw_clock(screen, fonts, 20, clock_y)