AvatarPy: assistente personale con volto 3D, voce, memoria e plugin

Riscrittura in Python dell'assistente Avatar con interfaccia HUD (derivata da Mark LIV, CC BY-NC 4.0, vedi NOTICE.md).
Tre motori (Claude API, server locale OpenAI-compatibile, Claude Code), voce Kokoro/macOS, Whisper MLX,
avatar 3D con sincronizzazione labiale, memoria per categorie, allegati con OCR, monitor con avvisi,
plugin per Calendario, Mail, Promemoria, Note, Musica, app, Mac, timer, meteo, contatti, Messaggi,
file, Comandi Rapidi, browser, Telegram, WhatsApp (archivio e tempo reale).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5.1 committed 2026-09-23 16:21:39 +02:00
commit ff79832c30
76 files changed
+82468

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
import * as THREE from 'three';
import { GLTFLoader } from './vendor/GLTFLoader.js';
const COLORS = { LISTENING: '#00ff88', THINKING: '#ffcc00', PROCESSING: '#ffcc00', SPEAKING: '#ff6b00', SLEEPING: '#3a8a9a', MUTED: '#ff3366' };
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
const scene = new THREE.Scene();
scene.background = new THREE.Color('#00060a');
const camera = new THREE.PerspectiveCamera(28, 1, 0.05, 50);
const hemi = new THREE.HemisphereLight('#bfe8ff', '#0a1a2a', 1.2);
const key = new THREE.DirectionalLight('#ffffff', 2.2); key.position.set(1.2, 2.4, 2.0);
const fill = new THREE.DirectionalLight('#9fd8ff', 0.8); fill.position.set(-1.5, 1.0, 1.5);
const rim = new THREE.DirectionalLight('#00d4ff', 1.6); rim.position.set(-2, 1.5, -2);
scene.add(hemi, key, fill, rim);
const state = { name: 'LISTENING', level: 0, open: 0, width: 0, framing: 'bust' };
const smooth = { open: 0, width: 0, nod: 0, yaw: 0, pitch: 0, glanceX: 0, glanceY: 0, glanceUntil: 0, light: 1 };
let model = null, mixer = null, head = null;
const eyes = [];
const restQ = new Map(); // orientamento nello spazio della posa neutra, per testa e occhi
const headPos = new THREE.Vector3(0, 1.6, 0);
const morphMeshes = [];
const mouth = { hasShapes: false, map: {} };
const blink = { next: 2, until: 0 };
const levels = new Array(36).fill(0);
const clock = new THREE.Clock();
let modelHeight = 1.7;
const modelCenter = new THREE.Vector3();
function resize() {
const w = window.innerWidth || canvas.clientWidth, h = window.innerHeight || canvas.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();
function findMorphs(root) {
root.traverse((o) => {
if (o.isMesh && o.morphTargetDictionary && o.morphTargetInfluences) morphMeshes.push(o);
});
const names = new Set();
for (const m of morphMeshes) for (const k of Object.keys(m.morphTargetDictionary)) names.add(k);
const pick = (...cands) => cands.find((c) => names.has(c));
mouth.map = {
open: pick('jawOpen', 'JawOpen', 'mouthOpen', 'MouthOpen', 'viseme_aa', 'mouth_open', 'A'),
aa: pick('viseme_aa'),
smile: pick('mouthSmileLeft', 'mouthSmile', 'mouthSmile_L'),
smileR: pick('mouthSmileRight', 'mouthSmile_R'),
wide: pick('viseme_I', 'viseme_E'),
round: pick('viseme_O', 'mouthFunnel', 'mouthPucker', 'O'),
pucker: pick('mouthPucker'),
blinkL: pick('eyeBlinkLeft', 'eyesClosed'),
blinkR: pick('eyeBlinkRight'),
browUp: pick('browInnerUp'),
close: pick('mouthClose'),
};
mouth.hasShapes = Boolean(mouth.map.open);
document.getElementById('hint').textContent = (mouth.hasShapes ? 'labiale: attiva' : 'labiale: modello senza blend shape') + ' · clic: busto / figura intera';
}
function setMorph(name, value) {
if (!name) return;
for (const m of morphMeshes) {
const i = m.morphTargetDictionary[name];
if (i !== undefined) m.morphTargetInfluences[i] = value;
}
}
function measure() {
const box = new THREE.Box3().setFromObject(model);
modelHeight = Math.max(0.1, box.max.y - box.min.y);
box.getCenter(modelCenter);
}
function frame() {
if (!model) return;
if (head) head.getWorldPosition(headPos);
const h = modelHeight;
if (state.framing === 'bust') {
camera.position.set(headPos.x, headPos.y + h * 0.02, headPos.z + h * 0.56);
camera.lookAt(headPos.x, headPos.y - h * 0.015, headPos.z);
} else {
camera.position.set(modelCenter.x, modelCenter.y + h * 0.05, modelCenter.z + h * 1.9);
camera.lookAt(modelCenter.x, modelCenter.y, modelCenter.z);
}
}
canvas.addEventListener('click', () => { state.framing = state.framing === 'bust' ? 'full' : 'bust'; frame(); });
const MODEL = new URLSearchParams(location.search).get('model') || 'allegra_2';
new GLTFLoader().load(`./models/${encodeURIComponent(MODEL)}.glb`, (gltf) => {
model = gltf.scene;
scene.add(model);
model.traverse((o) => {
if (o.isBone) {
if (o.name === 'Head') head = o;
else if (o.name === 'LeftEye' || o.name === 'RightEye') eyes.push(o);
}
if (o.isMesh) o.frustumCulled = false;
});
findMorphs(model);
model.updateMatrixWorld(true);
for (const b of [head, ...eyes]) if (b) restQ.set(b, b.getWorldQuaternion(new THREE.Quaternion()));
let clipTracks = 0;
if (gltf.animations.length) {
mixer = new THREE.AnimationMixer(model);
const clip = gltf.animations[0].clone();
// Testa e occhi li orientiamo noi; il bacino resta fermo così il corpo guarda avanti.
clip.tracks = clip.tracks.filter((tr) => !/^(Head|Neck|Hips|LeftEye|RightEye)\./.test(tr.name));
clipTracks = clip.tracks.length;
const action = mixer.clipAction(clip);
action.setLoop(THREE.LoopRepeat); action.play();
}
document.getElementById('msg').remove();
measure();
if (head) head.getWorldPosition(headPos);
console.log('modello', MODEL, ': altezza', modelHeight.toFixed(3), 'testa', headPos.toArray().map((v) => v.toFixed(2)).join(','), 'blend shape', mouth.hasShapes, 'occhi', eyes.length, 'tracce', clipTracks);
frame();
}, undefined, (err) => {
document.getElementById('msg').textContent = 'Modello non caricato: ' + (err.message || err);
});
const qOffset = new THREE.Quaternion(), qParent = new THREE.Quaternion(), euler = new THREE.Euler();
/** Orienta un osso nello spazio: posa neutra + rotazione (pitch, yaw) in assi mondo. */
function aimBone(bone, pitch, yaw, roll) {
const rest = restQ.get(bone);
if (!rest || !bone.parent) return;
bone.parent.updateWorldMatrix(true, false);
bone.parent.getWorldQuaternion(qParent).invert();
euler.set(pitch, yaw, roll || 0, 'YXZ');
qOffset.setFromEuler(euler);
bone.quaternion.copy(qParent).multiply(qOffset).multiply(rest);
}
function render() {
requestAnimationFrame(render);
const dt = Math.min(0.05, clock.getDelta());
const t = clock.elapsedTime;
if (mixer) mixer.update(dt);
// Posa per stato (pitch positivo = guarda in basso)
let targetPitch = 0, targetYaw = 0, light = 1;
if (state.name === 'THINKING' || state.name === 'PROCESSING') { targetPitch = -0.14; targetYaw = 0.28 + Math.sin(t * 0.7) * 0.05; }
else if (state.name === 'SLEEPING') { targetPitch = 0.32; light = 0.45; }
else if (state.name === 'LISTENING') { targetYaw = Math.sin(t * 0.5) * 0.05; targetPitch = Math.sin(t * 0.8) * 0.02; }
else if (state.name === 'SPEAKING') { targetYaw = Math.sin(t * 0.9) * 0.04; }
if (t < smooth.glanceUntil) { targetYaw += smooth.glanceX; targetPitch += smooth.glanceY; }
const k = Math.min(1, dt * 4);
smooth.yaw += (targetYaw - smooth.yaw) * k;
smooth.pitch += (targetPitch - smooth.pitch) * k;
smooth.light += (light - smooth.light) * k;
hemi.intensity = 1.2 * smooth.light; key.intensity = 2.2 * smooth.light; fill.intensity = 0.8 * smooth.light;
const nodTarget = state.name === 'SPEAKING' ? state.level * 0.07 : 0;
smooth.nod += (nodTarget - smooth.nod) * Math.min(1, dt * 12);
if (head) aimBone(head, smooth.pitch + smooth.nod, smooth.yaw, Math.sin(t * 0.9) * 0.012);
for (const e of eyes) aimBone(e, smooth.pitch * 0.5 + Math.sin(t * 0.37) * 0.03, smooth.yaw * 0.6 + Math.sin(t * 0.23) * 0.04, 0);
// Bocca e viso
const openTarget = state.name === 'SPEAKING' ? state.open : 0;
smooth.open += (openTarget - smooth.open) * Math.min(1, dt * 18);
smooth.width += ((state.name === 'SPEAKING' ? state.width : 0) - smooth.width) * Math.min(1, dt * 12);
if (mouth.hasShapes) {
const o = Math.min(1, smooth.open), w = smooth.width;
setMorph(mouth.map.open, o * 0.45);
setMorph(mouth.map.aa, o * 0.55 * (1 - Math.abs(w) * 0.5));
setMorph(mouth.map.wide, Math.max(0, w) * o * 0.6);
setMorph(mouth.map.round, Math.max(0, -w) * o * 0.7);
setMorph(mouth.map.pucker, Math.max(0, -w) * o * 0.2);
const smileBase = state.name === 'LISTENING' ? 0.07 : 0.03;
setMorph(mouth.map.smile, smileBase + Math.max(0, w) * 0.2);
setMorph(mouth.map.smileR, smileBase + Math.max(0, w) * 0.2);
setMorph(mouth.map.close, 0); // usato da solo deforma le labbra: mai a riposo
setMorph(mouth.map.browUp, state.name === 'THINKING' || state.name === 'PROCESSING' ? 0.5 : (state.name === 'LISTENING' ? 0.15 : 0));
if (t > blink.next) { blink.until = t + 0.13; blink.next = t + 2 + Math.random() * 4; }
const closed = state.name === 'SLEEPING' ? 1 : (t < blink.until ? 1 : 0);
setMorph(mouth.map.blinkL, closed); setMorph(mouth.map.blinkR, closed);
}
if (state.framing === 'bust' && head) frame();
renderer.render(scene, camera);
drawOverlay();
}
const wave = document.getElementById('wave'), wctx = wave.getContext('2d'), stateEl = document.getElementById('state');
let dispLevel = 0;
function drawOverlay() {
dispLevel += (state.level - dispLevel) * 0.3;
levels.push(dispLevel); levels.shift();
const col = COLORS[state.name] || '#00d4ff';
stateEl.style.color = col; stateEl.style.textShadow = `0 0 8px ${col}88`;
stateEl.textContent = '● ' + state.name;
wctx.clearRect(0, 0, wave.width, wave.height);
const n = levels.length, bw = wave.width / n;
for (let i = 0; i < n; i++) {
const h = 3 + levels[i] * (wave.height - 4) + Math.sin(i * 0.7 + performance.now() / 600) * 1.5;
wctx.fillStyle = col; wctx.globalAlpha = 0.35 + 0.65 * (i / n);
wctx.fillRect(i * bw + 1, (wave.height - h) / 2, bw - 3, h);
}
wctx.globalAlpha = 1;
}
window.avatar3d = {
update(level, open, width, name) { state.level = level; state.open = open; state.width = width; if (name) state.name = name; },
setState(name) { state.name = name; },
glance(dx, dy, hold) { smooth.glanceX = dx * 0.3; smooth.glanceY = -dy * 0.2; smooth.glanceUntil = clock.elapsedTime + (hold || 1.1); },
setFraming(f) { state.framing = f; frame(); },
status() { return { loaded: Boolean(model), animation: Boolean(mixer), head: Boolean(head), blendShapes: mouth.hasShapes, morphs: morphMeshes.length, eyes: eyes.length }; },
};
render();
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>Avatar 3D</title>
<style>
html, body { margin: 0; height: 100%; background: #00060a; overflow: hidden; font-family: Menlo, monospace; }
#scene { position: absolute; left: 0; top: 0; width: 100%; height: 100%; display: block; }
#overlay { position: absolute; left: 0; right: 0; bottom: 14px; text-align: center; pointer-events: none; }
#state { font-size: 15px; font-weight: bold; letter-spacing: 2px; color: #00d4ff; text-shadow: 0 0 8px rgba(0,212,255,.5); }
#wave { display: block; margin: 8px auto 0; }
#hint { position: absolute; top: 10px; right: 12px; font-size: 11px; color: #3a8a9a; }
#msg { position: absolute; top: 45%; left: 0; right: 0; text-align: center; color: #79c0d2; font-size: 14px; }
</style>
<script type="importmap">{ "imports": { "three": "./vendor/three.module.js" } }</script>
</head>
<body>
<canvas id="scene"></canvas>
<div id="msg">Carico l'avatar…</div>
<div id="hint">clic: busto / figura intera</div>
<div id="overlay">
<div id="state">● LISTENING</div>
<canvas id="wave" width="360" height="40"></canvas>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
Metti qui i modelli GLB degli avatar (per esempio esportati da Avaturn con i blend shape).
Il nome del file diventa il nome nel menu "Avatar 3D" delle impostazioni.
File diff suppressed because it is too large. Load diff
+4727
View File
File diff suppressed because it is too large. Load diff
+54571
View File
File diff suppressed because it is too large. Load diff