Spaces:
Sleeping
Sleeping
| 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 ( | |
| <group ref={group} position={[0, -2.7, 0]} scale={[2, 2, 2]} dispose={null} {...props}> | |
| {avatarData?.scene && <primitive object={avatarData.scene} />} | |
| </group> | |
| ); | |
| } | |
| useGLTF.preload("/models/avatar-version1.glb"); | |
| useGLTF.preload("/models/animations.glb"); |