/* ===================================================================== useAvatarMotion — 단일 requestAnimationFrame 모션 루프 출처: docs/DESIGN_CONCEPT.md §4.3(립싱크) / §4.4(모션 진폭) / §4.7(rAF 1루프) 채널: 어깨호흡(translateY) + 깜빡임(scaleY) + 립싱크(RMS→mouth scaleY) + saccade(시선 미세 이동) 원칙(§4.4): 동시에 움직이는 채널 ≤ 2. transform/opacity 만 갱신(layout 금지). prefers-reduced-motion 은 호출 측(ClientAvatar)에서 분기 — 이 훅은 모션 활성 시만 돈다. ⚠ 런타임 코드: performance.now()/Math.random() 사용 OK (워크플로 스크립트 아님). ── Rive 2차 교체 지점 ───────────────────────────────────────────── Rive 전환 시 이 훅 전체가 .riv state-machine 의 input(speak amplitude, blink, gaze, breath) wiring 으로 교체된다. 반환 MotionFrame 계약만 유지하면 ClientAvatar 의 SVG 바인딩은 그대로 둘 수 있다. ===================================================================== */ import { useEffect, useRef, useState } from "react"; import type { AffectParams, AvatarState } from "./persona"; /** 매 프레임 컴포넌트로 내려가는 transform 값 묶음 */ export interface MotionFrame { /** 어깨/몸 호흡 translateY px (음수=올라감) */ breath: number; /** 깜빡임 눈 scaleY (1=뜸, 0.08=감김) */ blink: number; /** 입 열림 0~1 (립싱크) */ mouth: number; /** saccade 시선 x 미세 오프셋 px */ saccadeX: number; /** saccade 시선 y 미세 오프셋 px */ saccadeY: number; } const IDLE_FRAME: MotionFrame = { breath: 0, blink: 1, mouth: 0, saccadeX: 0, saccadeY: 0, }; /* ── RMS 평활 (지수평활 τ≈180ms) + 데드존 0.04 (§4.3) ────────────────── raw RMS 직결하면 입이 경련함. dt 기반 alpha 로 프레임레이트 독립적으로 평활. */ function smoothMouthFromRMS( analyser: AnalyserNode, buf: Float32Array, prev: number, dtSec: number, ): number { analyser.getFloatTimeDomainData(buf); let sum = 0; for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i]; const rms = Math.sqrt(sum / buf.length); // 데드존: 무음 구간 입 떨림 제거 const target = rms < 0.04 ? 0 : Math.min(1, rms * 3.2); // τ≈180ms 지수평활 (dt 보정 → 프레임레이트 무관) const tau = 0.18; const alpha = 1 - Math.exp(-dtSec / tau); return prev + (target - prev) * alpha; } /* ── analyser 없을 때의 립싱크 폴백 ───────────────────────────────────── 외부에서 speakingProgress(0~1, 발화 타이핑 진행)를 명시적으로 줄 때만 낮은 진폭으로 움직인다. 실제 음성 분석이 없으면 합성 발화처럼 보이지 않게 닫아 둔다. */ function fallbackMouthTarget(tSec: number, progress: number | null): number { if (progress !== null) { // 타이핑 진행 중에만 입을 움직임. 진행이 멈추면(완료) 닫힘. if (progress >= 1) return 0; // 음절 리듬 ~6Hz, 미세 변주 const syl = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 5.5); const jitter = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 11 + 1.3); const base = 0.18 + 0.42 * syl * jitter; return Math.min(0.85, base); } // analyser·speakingProgress 모두 없을 때(예: 텍스트 턴) — 차분한 의사 발화 모션을 // 기본 제공해 'speaking' 상태에서 입이 보이게 움직이도록 한다(이전엔 0으로 정지). const syl = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 4.2); const jitter = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 8.5 + 0.7); return Math.min(0.7, 0.12 + 0.34 * syl * jitter); } export interface AvatarMotionOptions { state: AvatarState; params: AffectParams; analyser: AnalyserNode | null; /** analyser 없을 때 선택적으로 쓰는 타이핑 진행도 0~1 */ speakingProgress?: number | null; /** false 면 루프 정지(reduced-motion) — IDLE_FRAME 고정 */ enabled: boolean; } export function useAvatarMotion({ state, params, analyser, speakingProgress = null, enabled, }: AvatarMotionOptions): MotionFrame { const [frame, setFrame] = useState(IDLE_FRAME); // 최신값을 rAF 클로저가 리렌더 없이 읽도록 ref 미러 const stateRef = useRef(state); const paramsRef = useRef(params); const analyserRef = useRef(analyser); const progressRef = useRef(speakingProgress); stateRef.current = state; paramsRef.current = params; analyserRef.current = analyser; progressRef.current = speakingProgress; useEffect(() => { if (!enabled) { setFrame(IDLE_FRAME); return; } let raf = 0; const t0 = performance.now(); let lastT = t0; // 깜빡임 스케줄 (랜덤 간격, 120ms 감김) const p0 = paramsRef.current; let nextBlinkAt = 1200 + Math.random() * (p0.blinkMax - p0.blinkMin) * 1000; let blinkUntil = 0; // saccade 스케줄 (6~10s 간격, 0.3s 이동, ±3px) let nextSaccadeAt = 4000 + Math.random() * 4000; let saccadeStart = 0; let saccadeTargetX = 0; let saccadeTargetY = 0; let saccadeFromX = 0; let saccadeFromY = 0; let saccadeX = 0; let saccadeY = 0; let mouthPrev = 0; const audioBuf = new Float32Array(1024); const loop = (t: number) => { const p = paramsRef.current; const st = stateRef.current; const elapsed = t - t0; const dtSec = Math.min(0.05, (t - lastT) / 1000); // 탭 비활성 복귀 시 점프 클램프 lastT = t; const tSec = elapsed / 1000; // ── 1) 어깨/몸 호흡 (translateY 1.5~2.5px, 3.5~5.5s) ── const breath = Math.sin((tSec * 2 * Math.PI) / p.breathPeriod) * p.breathAmp; // ── 2) 깜빡임 (scaleY 1→0.08→1, 120ms, 간격 4~7s 랜덤) ── let blink = 1; if (blinkUntil === 0 && elapsed >= nextBlinkAt) { blinkUntil = elapsed + 120; } if (blinkUntil > 0) { if (elapsed >= blinkUntil) { blinkUntil = 0; nextBlinkAt = elapsed + p.blinkMin * 1000 + Math.random() * (p.blinkMax - p.blinkMin) * 1000; blink = 1; } else { // 0→감김(0.08)→복귀 삼각 근사 const phase = 1 - Math.abs((blinkUntil - elapsed) / 120 - 0.5) * 2; blink = 1 - phase * 0.92; } } // ── 3) saccade (시선 미세 이동 ±3px, 0.3s, 간격 6~10s; thinking 빈도↑) ── const saccadeInterval = st === "thinking" ? 3000 : 7000; if (saccadeStart === 0 && elapsed >= nextSaccadeAt) { saccadeStart = elapsed; saccadeFromX = saccadeX; saccadeFromY = saccadeY; // listening 은 학습자 응시 → saccade 거의 없음(중앙 복귀) const range = st === "listening" ? 1 : 3; saccadeTargetX = (Math.random() * 2 - 1) * range; saccadeTargetY = (Math.random() * 2 - 1) * (range * 0.6); } if (saccadeStart > 0) { const k = Math.min(1, (elapsed - saccadeStart) / 300); // ease-out const e = 1 - Math.pow(1 - k, 2); saccadeX = saccadeFromX + (saccadeTargetX - saccadeFromX) * e; saccadeY = saccadeFromY + (saccadeTargetY - saccadeFromY) * e; if (k >= 1) { saccadeStart = 0; nextSaccadeAt = elapsed + saccadeInterval * 0.6 + Math.random() * saccadeInterval * 0.6; } } // ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 정적 폴백) ── let mouth = mouthPrev; if (st === "speaking") { const a = analyserRef.current; if (a) { mouthPrev = smoothMouthFromRMS(a, audioBuf, mouthPrev, dtSec); } else { const target = fallbackMouthTarget(tSec, progressRef.current ?? null); const tau = 0.12; const alpha = 1 - Math.exp(-dtSec / tau); mouthPrev = mouthPrev + (target - mouthPrev) * alpha; } mouth = mouthPrev; } else if (mouthPrev > 0.001) { // 발화 종료 → 부드럽게 닫힘 const alpha = 1 - Math.exp(-dtSec / 0.1); mouthPrev = mouthPrev * (1 - alpha); if (mouthPrev < 0.005) mouthPrev = 0; mouth = mouthPrev; } else { mouth = 0; } setFrame({ breath, blink, mouth, saccadeX, saccadeY }); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); return () => cancelAnimationFrame(raf); }, [enabled]); return frame; }