import React, { useRef, useEffect, useState } from "react"; import { useGLTF, useAnimations } from "@react-three/drei"; import { useFrame } from "@react-three/fiber"; import * as THREE from "three"; import { VISEMES, Lipsync } from "wawa-lipsync"; const EXTENDED_VISEMES = { ...VISEMES, // Français PP: "viseme_PP", FF: "viseme_FF", DD: "viseme_DD", KK: "viseme_KK", CH: "viseme_CH", SS: "viseme_SS", NN: "viseme_NN", RR: "viseme_RR", AA: "viseme_AA", E: "viseme_E", I: "viseme_I", O: "viseme_O", U: "viseme_U", // Anglais TH: "viseme_TH", // /θ/, /ð/ (ex. "think", "this") ZH: "viseme_ZH", // /ʒ/ (ex. "measure") SH: "viseme_SH", // /ʃ/ (ex. "she") // Arabe Q: "viseme_Q", // /q/ (emphatique, ex. "ق") HA: "viseme_HA", // /ħ/ (pharyngal, ex. "ح") AA2: "viseme_AA2", // /ʕ/ (pharyngal, ex. "ع") ou voyelles ouvertes KH: "viseme_KH", // /x/ (ex. "خ") GH: "viseme_GH" // /ɣ/ (ex. "غ") }; const morphScale = { viseme_PP: 1.0, viseme_FF: 0.8, viseme_DD: 0.9, viseme_KK: 0.7, viseme_CH: 0.8, viseme_SS: 0.7, viseme_NN: 0.7, viseme_RR: 0.6, viseme_AA: 1.0, viseme_E: 0.9, viseme_I: 0.8, viseme_O: 0.9, viseme_U: 0.8, viseme_TH: 0.7, // Anglais: lèvres légèrement en avant viseme_ZH: 0.6, // Anglais: arrondi léger viseme_SH: 0.7, // Anglais: lèvres pincées viseme_Q: 0.8, // Arabe: mâchoire ouverte, gorge serrée viseme_HA: 0.7, // Arabe: pharyngal, lèvres neutres viseme_AA2: 1.0, // Arabe: voyelle ouverte pharyngale viseme_KH: 0.7, // Arabe: fricative, lèvres légèrement ouvertes viseme_GH: 0.6 // Arabe: fricative, gorge ouverte }; export default function AvatarModel({ audioRef, audioUrl, lang = "fr", ...props }) { const group = useRef(); const avatarData = useGLTF("/models/avatar-version1.glb"); const animationsData = useGLTF("/models/animations.glb"); const { actions } = useAnimations(animationsData?.animations || [], group); const [animation] = useState("Idle"); const [blink, setBlink] = useState(false); const lipsyncRef = useRef(null); const morphValues = useRef({}); const morphTargetsRef = useRef({}); const lastViseme = useRef(null); const visemeTransition = useRef(0); const debugMode = useRef(process.env.NODE_ENV === "development"); // Logs uniquement en dev // --- Cache morphTargets --- useEffect(() => { if (!avatarData?.scene) return; avatarData.scene.traverse(child => { if (child.isSkinnedMesh && child.morphTargetDictionary) { morphTargetsRef.current = { ...morphTargetsRef.current, ...child.morphTargetDictionary }; if (debugMode.current) { console.debug("Morph Targets disponibles:", child.morphTargetDictionary); } } }); }, [avatarData]); // --- Setup Lipsync et connexion dynamique sur changement de src --- useEffect(() => { if (!audioRef.current) return; const handleLoadStart = () => { if (!lipsyncRef.current) { lipsyncRef.current = new Lipsync(); } lipsyncRef.current.connectAudio(audioRef.current); if (debugMode.current) { console.debug(`Lipsync connecté (lang: ${lang}):`, audioRef.current.src); } }; audioRef.current.addEventListener("loadstart", handleLoadStart); if (audioRef.current.src) { handleLoadStart(); } return () => { audioRef.current.removeEventListener("loadstart", handleLoadStart); }; }, [audioRef, lang]); // --- Animation Idle --- useEffect(() => { if (actions[animation]) actions[animation].reset().fadeIn(0.5).play(); return () => { if (actions[animation]) actions[animation].fadeOut(0.5); }; }, [animation, actions]); const lerpMorphTarget = (target, value, speed = 0.15) => { if (!avatarData?.scene) return; avatarData.scene.traverse(child => { if (child.isSkinnedMesh && child.morphTargetDictionary) { const index = child.morphTargetDictionary[target]; if (index !== undefined && child.morphTargetInfluences) { const current = morphValues.current[target] || child.morphTargetInfluences[index] || 0; const newValue = THREE.MathUtils.lerp(current, value, speed); child.morphTargetInfluences[index] = newValue; morphValues.current[target] = newValue; } } }); }; const mapToExtendedViseme = (phoneme) => { if (!phoneme) return null; let mapped; switch (phoneme) { case "x": mapped = EXTENDED_VISEMES.KH; break; default: mapped = EXTENDED_VISEMES[phoneme] || phoneme; } if (debugMode.current) { console.debug(`Lang: ${lang}, Phonème: ${phoneme}, Visème mappé: ${mapped}`); } return mapped; }; // --- Frame loop --- useFrame((_, delta) => { lerpMorphTarget("eyeBlinkLeft", blink ? 1 : 0, 0.2); lerpMorphTarget("eyeBlinkRight", blink ? 1 : 0, 0.2); if (lipsyncRef.current && audioRef.current && !audioRef.current.paused) { lipsyncRef.current.processAudio(); const phoneme = lipsyncRef.current.viseme; const energy = lipsyncRef.current.energy || 0; const viseme = mapToExtendedViseme(phoneme); if (viseme && viseme !== lastViseme.current) { lastViseme.current = viseme; visemeTransition.current = 0; } if (viseme) { // Ajuster la vitesse de transition par langue const transitionSpeed = lang === "ar" ? delta * 3 : lang === "en" ? delta * 5 : delta * 4; visemeTransition.current = Math.min(visemeTransition.current + transitionSpeed, 1); const targetValue = (morphScale[viseme] || 1.0) * THREE.MathUtils.lerp(0, 1, visemeTransition.current); lerpMorphTarget(viseme, targetValue, 0.3); Object.values(EXTENDED_VISEMES).forEach(v => { if (v !== viseme && (morphValues.current[v] || 0) > 0.01) { lerpMorphTarget(v, 0, 0.2); } }); } // Ajuster l'expressivité par langue const smileScale = lang === "ar" ? 1.7 : lang === "en" ? 1.4 : 1.5; const browScale = lang === "ar" ? 1.2 : lang === "en" ? 0.9 : 1.0; lerpMorphTarget("mouthSmile", Math.min(energy * smileScale, 0.8), 0.2); lerpMorphTarget("browInnerUp", Math.min(energy * browScale, 0.6), 0.15); } }); // --- Blink --- useEffect(() => { let blinkTimeout; const nextBlink = () => { blinkTimeout = setTimeout(() => { setBlink(true); setTimeout(() => { setBlink(false); nextBlink(); }, THREE.MathUtils.randInt(120, 250)); }, THREE.MathUtils.randInt(2000, 6000)); }; nextBlink(); return () => clearTimeout(blinkTimeout); }, []); return ( {avatarData?.scene && } ); } useGLTF.preload("/models/avatar-version1.glb"); useGLTF.preload("/models/animations.glb");