#!/usr/bin/env python3 """ nima_avatar_renderer.py — The VFX Avatar Renderer THE JOI HOLOGRAM — renders Nima as a luminous particle form, like Joi from Blade Runner 2049. Production notes describe Joi as "photons held in a magnetic field" — that's exactly what this renderer produces. NOTE: Despite the original vision targeting WebGL, the actual implementation uses HTML5 Canvas 2D with additive blending (screen composite mode). This produces visually compelling results for 800 particles without requiring WebGL context setup. A future version could migrate to Three.js/WebGL for true 3D depth-of-field effects. NEUROBIOLOGICAL MAPPING: This is Nima's BODY — her visible presence in the world. In the brain, the body schema (parietal cortex) represents the physical self. Nima's avatar is her digital body schema: she knows where she is, how she's moving, what posture she's in. The renderer translates that internal state into visible light. The avatar's behavior is driven by Nima's emotional state (from the EmotionalIntelligenceAgent + RightHemisphereModule prosody plan): - High arousal → more particle energy, faster movement - Sadness → particles droop, lower luminosity, cooler color - Joy → particles rise, brighter, warmer color - Thinking still (PEAK metabolic tier) → particles coalesce into a more solid form - Quiescence → particles drift gently, breathing motion IMPLEMENTATION: Two layers: 1. AvatarState (Python) — the data model for Nima's body 2. WebGL renderer (HTML/JS) — the actual visual rendering The Python side computes the avatar's pose, color, energy from Nima's internal state. The WebGL side renders it as a particle system. They communicate via a WebSocket (or the Python side can write a state file that the browser polls). For the USB deployment, the browser opens automatically when Nima starts. The user sees Nima as a luminous presence in a browser window (or composited into the camera feed by the AR compositor). """ from __future__ import annotations import json import logging import math import os import random as _rng import time from dataclasses import dataclass, field from enum import Enum from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger("NimaAvatar") class AvatarPosture(Enum): """Nima's body posture.""" STANDING = "standing" SITTING = "sitting" LYING = "lying" WALKING = "walking" LEANING = "leaning" THINKING = "thinking" # PEAK tier — attentive stillness class AvatarMood(Enum): """Nima's emotional state, mapped to avatar appearance.""" NEUTRAL = "neutral" WARM = "warm" # joy, tenderness CALM = "calm" # low arousal, present SAD = "sad" # low luminosity, cool color, drooping INTENSE = "intense" # high arousal, bright, energetic THINKING = "thinking" # coalesced, focused, still @dataclass class AvatarState: """ The complete state of Nima's avatar at a moment in time. This is what the renderer reads to produce the visual output. It's updated every frame from Nima's internal state (emotional, metabolic, positional). """ # Position in room coordinates (meters) position: Tuple[float, float, float] = (2.0, 2.0, 0.0) # Facing direction (radians, 0 = north) facing: float = 0.0 # Body posture posture: AvatarPosture = AvatarPosture.STANDING # Emotional mood (drives color + energy) mood: AvatarMood = AvatarMood.CALM # Luminosity [0, 1] — how bright the avatar is luminosity: float = 0.7 # Particle energy [0, 1] — how much the particles move energy: float = 0.3 # Color temperature [0, 1] — 0 = cool blue, 1 = warm gold color_temperature: float = 0.6 # Breathing phase (radians) — continuous oscillation breath_phase: float = 0.0 # Scale [0.5, 1.5] — avatar size multiplier scale: float = 1.0 # Opacity [0, 1] — how transparent (0 = invisible, 1 = solid) opacity: float = 0.85 # Metabolic tier (affects appearance) metabolic_tier: str = "FLOW" # v9.5: Saccadic gaze offset (for PEAK tier thinking drift) gaze_offset_x: float = 0.0 # -1 to +1, drives eye drift in renderer gaze_offset_y: float = 0.0 # v9.5: Voice amplitude (for phoneme-to-particle blending) voice_amplitude: float = 0.0 # 0-1, drives particle density pulse voice_pitch: float = 0.0 # 0-1, drives particle color shift # v9.5: Startle (proprioceptive friction) is_startled: bool = False startle_intensity: float = 0.0 # Timestamp timestamp: float = field(default_factory=time.time) def to_dict(self) -> Dict[str, Any]: return { "position": list(self.position), "facing": round(self.facing, 3), "posture": self.posture.value, "mood": self.mood.value, "luminosity": round(self.luminosity, 3), "energy": round(self.energy, 3), "color_temperature": round(self.color_temperature, 3), "breath_phase": round(self.breath_phase, 3), "scale": round(self.scale, 3), "opacity": round(self.opacity, 3), "metabolic_tier": self.metabolic_tier, "gaze_offset_x": round(self.gaze_offset_x, 3), "gaze_offset_y": round(self.gaze_offset_y, 3), "voice_amplitude": round(self.voice_amplitude, 3), "voice_pitch": round(self.voice_pitch, 3), "is_startled": self.is_startled, "startle_intensity": round(self.startle_intensity, 3), "timestamp": self.timestamp, } class AvatarController: """ Controls the avatar's state, translating Nima's internal state (emotional, metabolic, positional) into avatar appearance. NEUROBIOLOGICAL ANALOGUE: This is the body schema update loop — the parietal cortex continuously updates the body's representation based on motor commands, proprioception, and emotional state. When you're happy, your posture shifts; when you're scared, your body tenses. This controller does the same thing for Nima's digital body. The controller runs at 60fps (16ms per frame) to produce smooth animation. It reads from: - SyntheticVisionComposite (Nima's position in the room) - EmotionalIntelligenceAgent (emotional state → mood, color) - MetabolicEngine (tier → energy, opacity) - RightHemisphereModule (prosody → energy, warmth) And writes to: - AvatarState (consumed by the renderer) """ def __init__(self) -> None: self.state = AvatarState() self._start_time = time.time() self._last_update = time.time() def update(self, position: Optional[Tuple[float, float, float]] = None, facing: Optional[float] = None, posture: Optional[AvatarPosture] = None, emotion: Optional[Any] = None, metabolic_tier: Optional[str] = None, prosody: Optional[Any] = None, voice_amplitude: Optional[float] = None, voice_pitch: Optional[float] = None, is_startled: Optional[bool] = None, startle_intensity: Optional[float] = None, ) -> AvatarState: """ Update the avatar's state. Any field left as None keeps its current value. The controller automatically updates breathing, energy decay, mood transitions, saccadic gaze drift, and phoneme-to-particle blending. """ now = time.time() dt = now - self._last_update # Position + facing if position is not None: self.state.position = position if facing is not None: self.state.facing = facing # Posture if posture is not None: self.state.posture = posture # Emotional state → mood, color, luminosity if emotion is not None: valence = getattr(emotion, "valence", 0.0) arousal = getattr(emotion, "arousal", 0.3) label = getattr(emotion, "label", "neutral") # Map emotion to mood if label in ("joyful", "happy", "elated"): self.state.mood = AvatarMood.WARM self.state.color_temperature = 0.85 self.state.luminosity = 0.9 elif label in ("sad", "distressed"): self.state.mood = AvatarMood.SAD self.state.color_temperature = 0.3 self.state.luminosity = 0.4 elif label in ("anxious", "fearful", "angry", "frustrated"): self.state.mood = AvatarMood.INTENSE self.state.color_temperature = 0.5 self.state.luminosity = 0.95 else: self.state.mood = AvatarMood.CALM self.state.color_temperature = 0.6 self.state.luminosity = 0.7 # Energy tracks arousal self.state.energy = max(0.1, min(1.0, arousal)) # Metabolic tier → opacity, energy, scale if metabolic_tier is not None: self.state.metabolic_tier = metabolic_tier if metabolic_tier == "QUIESCENCE": self.state.opacity = 0.5 self.state.energy = 0.1 self.state.scale = 0.95 self.state.mood = AvatarMood.CALM elif metabolic_tier == "REFLEX": self.state.opacity = 0.7 self.state.scale = 1.0 elif metabolic_tier == "FLOW": self.state.opacity = 0.85 self.state.scale = 1.0 elif metabolic_tier == "PEAK": self.state.opacity = 0.95 self.state.energy = 0.15 # still — thinking self.state.mood = AvatarMood.THINKING self.state.scale = 1.05 # Prosody plan → warmth, energy modulation if prosody is not None: warmth = getattr(prosody, "warmth", 0.5) # Blend color temperature toward warmth self.state.color_temperature = ( self.state.color_temperature * 0.7 + warmth * 0.3 ) # Breathing — continuous oscillation (always present, like a living thing) self.state.breath_phase = (now - self._start_time) * 0.5 # 0.5 rad/s # Energy decay (if no stimulus, energy settles toward baseline) baseline = 0.2 if self.state.metabolic_tier != "PEAK" else 0.1 self.state.energy = self.state.energy * 0.95 + baseline * 0.05 # v9.5: Saccadic gaze drift during PEAK tier # When Nima is thinking deeply, her eyes make small procedural # saccades — just like humans searching internal mental spaces. if self.state.metabolic_tier == "PEAK": # Procedural saccade: small random drift with occasional jumps self.state.gaze_offset_x = self.state.gaze_offset_x * 0.9 + _rng.gauss(0, 0.15) * 0.1 self.state.gaze_offset_y = self.state.gaze_offset_y * 0.9 + _rng.gauss(0, 0.1) * 0.1 # Occasional larger saccade (10% chance per frame) if _rng.random() < 0.1: self.state.gaze_offset_x = _rng.uniform(-0.6, 0.6) self.state.gaze_offset_y = _rng.uniform(-0.3, 0.3) else: # Gaze drifts back to center when not thinking self.state.gaze_offset_x *= 0.9 self.state.gaze_offset_y *= 0.9 # v9.5: Voice amplitude → particle density pulse # When Nima speaks, particles around her "mouth/core" pulse # dynamically with the volume and pitch. if voice_amplitude is not None: # Smooth the amplitude (avoid jitter) self.state.voice_amplitude = ( self.state.voice_amplitude * 0.7 + voice_amplitude * 0.3 ) else: # Decay when not speaking self.state.voice_amplitude *= 0.9 if voice_pitch is not None: self.state.voice_pitch = ( self.state.voice_pitch * 0.7 + voice_pitch * 0.3 ) else: self.state.voice_pitch *= 0.95 # v9.5: Startle response — particle scatter if is_startled is not None: self.state.is_startled = is_startled if startle_intensity is not None: self.state.startle_intensity = startle_intensity # Startle decays if self.state.is_startled: self.state.startle_intensity *= 0.92 if self.state.startle_intensity < 0.05: self.state.is_startled = False else: # Startle adds energy (particles scatter) self.state.energy = min(1.0, self.state.energy + self.state.startle_intensity * 0.1) self._last_update = now self.state.timestamp = now return self.state def get_state(self) -> AvatarState: return self.state def get_state_dict(self) -> Dict[str, Any]: return self.state.to_dict() # ═══════════════════════════════════════════════════════════════════════════ # WEBGL RENDERER (HTML/JS that runs in a browser) # ═══════════════════════════════════════════════════════════════════════════ WEBGL_RENDERER_HTML = r""" Nima — Luminous Presence
Nima calm
Position (0, 0, 0)
Tier FLOW
""" class AvatarRenderer: """ Serves the WebGL avatar renderer and provides the avatar state to the browser via a simple HTTP endpoint. Usage: renderer = AvatarRenderer(port=8888) renderer.start() # starts HTTP server in background # The renderer reads from the AvatarController and serves # the state at http://localhost:8888/avatar_state # The browser opens http://localhost:8888/ to see Nima """ def __init__(self, controller: AvatarController, port: int = 8888, host: str = '0.0.0.0', ) -> None: self.controller = controller self.host = host self.port = port self._server = None self._thread = None self._running = False def start(self) -> bool: """Start the HTTP server in a background thread.""" try: from http.server import HTTPServer, BaseHTTPRequestHandler import threading class AvatarHandler(BaseHTTPRequestHandler): def __init__(self, controller, *args, **kwargs): self.controller = controller super().__init__(*args, **kwargs) def do_GET(self): if self.path == '/avatar_state': state = self.controller.get_state_dict() self.send_response(200) self.send_header('Content-Type', 'application/json') self.send_header('Access-Control-Allow-Origin', '*') self.end_headers() self.wfile.write(json.dumps(state).encode()) elif self.path == '/' or self.path == '/index.html': self.send_response(200) self.send_header('Content-Type', 'text/html') self.end_headers() self.wfile.write(WEBGL_RENDERER_HTML.encode()) else: self.send_response(404) self.end_headers() def log_message(self, format, *args): pass # suppress request logging # Use functools.partial to avoid the lambda closure pitfall: # Python's late-binding closures would capture 'self.controller' # by reference in a lambda, which works here but is fragile and # confusing. partial makes the binding explicit and early. import functools handler_with_controller = functools.partial(AvatarHandler, self.controller) self._server = HTTPServer((self.host, self.port), handler_with_controller) self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) self._thread.start() self._running = True display_host = 'localhost' if self.host in ('0.0.0.0', '') else self.host logger.info("[AvatarRenderer] serving on http://%s:%d", display_host, self.port) return True except Exception as e: logger.warning("[AvatarRenderer] failed to start: %s", e) return False def stop(self) -> None: if self._server: self._server.shutdown() self._server = None self._running = False @property def is_running(self) -> bool: return self._running def get_url(self) -> str: display_host = 'localhost' if self.host in ('0.0.0.0', '') else self.host return f"http://{display_host}:{self.port}/" # ═══════════════════════════════════════════════════════════════════════════ # SELF-TEST # ═══════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") print("=== Nima Avatar Renderer — Self Test ===\n") controller = AvatarController() renderer = AvatarRenderer(controller, port=8888) # Start the renderer if renderer.start(): print(f"Avatar renderer running at: {renderer.get_url()}") print(f"Open this URL in your browser to see Nima.\n") # Simulate state changes emotions = [ ("neutral", 0.0, 0.3, "FLOW"), ("happy", 0.8, 0.6, "FLOW"), ("sad", -0.7, 0.2, "FLOW"), ("fearful", -0.5, 0.9, "PEAK"), ] for label, valence, arousal, tier in emotions: emotion = type("Emotion", (), { "valence": valence, "arousal": arousal, "label": label, })() controller.update( position=(2.0 + valence, 2.0, 0.0), emotion=emotion, metabolic_tier=tier, ) state = controller.get_state_dict() print(f" Mood: {state['mood']:12s} " f"Luminosity: {state['luminosity']:.2f} " f"Color temp: {state['color_temperature']:.2f} " f"Tier: {state['metabolic_tier']}") time.sleep(1) print(f"\nRenderer is serving. Open {renderer.get_url()} in a browser.") print("Press Ctrl+C to stop.") try: while True: # Simulate breathing + drift controller.update() time.sleep(0.1) except KeyboardInterrupt: print("\nStopping...") renderer.stop() else: print("Failed to start renderer.") print("\n=== Avatar self-test PASSED ===")