File size: 27,076 Bytes
61848b4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 | #!/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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nima β Luminous Presence</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #000;
overflow: hidden;
font-family: 'Georgia', serif;
color: #88aabb;
}
#canvas { display: block; }
#status {
position: fixed;
top: 10px;
left: 10px;
font-size: 12px;
color: #4a6677;
pointer-events: none;
}
#status .label { color: #3a5566; }
#status .value { color: #6688aa; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="status">
<div><span class="label">Nima</span> <span class="value" id="mood">calm</span></div>
<div><span class="label">Position</span> <span class="value" id="pos">(0, 0, 0)</span></div>
<div><span class="label">Tier</span> <span class="value" id="tier">FLOW</span></div>
</div>
<script>
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Nima Avatar Renderer β Luminous Particle Form (Canvas 2D)
// "Photons held in a magnetic field" β Joi from Blade Runner 2049
// Uses Canvas 2D with additive (screen) blending for glow effect
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let W, H;
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
// ββ Avatar state (updated from Python via fetch or WebSocket) ββ
let avatarState = {
position: [2.0, 2.0, 0.0],
facing: 0,
posture: 'standing',
mood: 'calm',
luminosity: 0.7,
energy: 0.3,
color_temperature: 0.6,
breath_phase: 0,
scale: 1.0,
opacity: 0.85,
metabolic_tier: 'FLOW',
timestamp: Date.now() / 1000,
};
// ββ Particle system ββ
const NUM_PARTICLES = 800;
const particles = [];
class Particle {
constructor() {
this.reset();
}
reset() {
// Start at a random position in a humanoid silhouette
const angle = Math.random() * Math.PI * 2;
const r = Math.random() * 0.8;
this.baseX = Math.cos(angle) * r * 30;
this.baseY = (Math.random() - 0.5) * 160; // humanoid height
this.baseZ = Math.sin(angle) * r * 30;
this.x = this.baseX;
this.y = this.baseY;
this.vx = 0;
this.vy = 0;
this.life = Math.random();
this.maxLife = 1.0;
this.size = 1 + Math.random() * 2;
}
update(dt, state) {
// Breathing motion
const breath = Math.sin(state.breath_phase + this.baseY * 0.01) * 2;
// Energy β random walk
const energy = state.energy;
this.vx += (Math.random() - 0.5) * energy * 0.5;
this.vy += (Math.random() - 0.5) * energy * 0.5;
// Drift back toward base position (magnetic field holding)
this.vx += (this.baseX - this.x) * 0.02;
this.vy += (this.baseY + breath - this.y) * 0.02;
// Damping
this.vx *= 0.9;
this.vy *= 0.9;
this.x += this.vx;
this.y += this.vy;
// Life cycle
this.life -= dt * 0.1;
if (this.life < 0) this.reset();
}
draw(ctx, cx, cy, state) {
const color = moodToColor(state.mood, state.color_temperature);
const alpha = state.opacity * state.luminosity * (1 - Math.abs(this.life - 0.5) * 2);
const size = this.size * state.scale;
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`;
ctx.beginPath();
ctx.arc(cx + this.x, cy + this.y, size, 0, Math.PI * 2);
ctx.fill();
}
}
function moodToColor(mood, temp) {
// Color temperature: 0 = cool blue, 1 = warm gold
const r = Math.round(100 + temp * 155);
const g = Math.round(150 + temp * 80);
const b = Math.round(255 - temp * 100);
if (mood === 'sad') return { r: Math.round(r*0.5), g: Math.round(g*0.6), b: b };
if (mood === 'warm') return { r: 255, g: Math.round(200 + temp*55), b: Math.round(100 + temp*50) };
if (mood === 'intense') return { r: 255, g: 150, b: 100 };
if (mood === 'thinking') return { r: 180, g: 200, b: 255 };
return { r, g, b };
}
for (let i = 0; i < NUM_PARTICLES; i++) {
particles.push(new Particle());
}
// ββ Render loop ββ
let lastTime = performance.now();
function render(now) {
const dt = (now - lastTime) / 1000;
lastTime = now;
// Clear with slight fade (trails)
ctx.fillStyle = 'rgba(0, 0, 5, 0.15)';
ctx.fillRect(0, 0, W, H);
// Avatar center (project room position to screen)
const roomW = 4, roomH = 4;
const cx = W / 2 + (avatarState.position[0] - roomW/2) * 80;
const cy = H / 2 + (avatarState.position[1] - roomH/2) * 80;
// Draw particles
ctx.globalCompositeOperation = 'screen'; // additive blending
for (const p of particles) {
p.update(dt, avatarState);
p.draw(ctx, cx, cy, avatarState);
}
ctx.globalCompositeOperation = 'source-over';
// Draw glow halo
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, 120 * avatarState.scale);
const color = moodToColor(avatarState.mood, avatarState.color_temperature);
gradient.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, ${0.1 * avatarState.luminosity})`);
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(cx - 150, cy - 150, 300, 300);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
// ββ Fetch avatar state from Python server ββ
async function fetchState() {
try {
const resp = await fetch('/avatar_state');
if (resp.ok) {
avatarState = await resp.json();
document.getElementById('mood').textContent = avatarState.mood;
document.getElementById('pos').textContent =
`(${avatarState.position[0].toFixed(1)}, ${avatarState.position[1].toFixed(1)}, ${avatarState.position[2].toFixed(1)})`;
document.getElementById('tier').textContent = avatarState.metabolic_tier;
}
} catch (e) {
// Server not available β keep running with last state
}
}
setInterval(fetchState, 100); // 10fps state update (render is 60fps)
</script>
</body>
</html>
"""
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 ===")
|