아바타 RMS 립싱크(canvas) — 코드 레벨 동작 흐름
마지막 업데이트 2026-07-22
TL;DR
핑퐁이 아바타의 입모양을 AI 발화 음량(RMS)에 비례해 연속 제어한다. 기존 idle/talking 2-video CSS 토글 대신, Canvas에 idle 위로 talking을 RMS 비례 투명도로 합성하는 mouth_sink/taeyang.html 방식으로 진화했다.
제어 축이 둘이다 — on/off는 아바타별(Avatar.rmsLipSyncEnabled), 세부 수치는 전역 공통(LanguageGuardSettings.avatarRms 9개 파라미터). 전역 on/off는 의도적으로 제거됐다.
핵심 로직 hooks/use-avatar-rms.ts 한 곳이 매 프레임 RMS → talking 재생/정지 → 재생속도 → opacity 이징 → canvas 합성까지 처리하며, React 리렌더 없이 requestAnimationFrame으로만 돈다.
그 문서는 talking 영상의
style.opacity를 ref로 직접 조작하던 canvas 이전 1차 구현이다. 본 문서는 그 이후 ① canvas 합성 전환 ② 아바타별 on/off ③ 전역 파라미터 튜닝 UI ④ 발화 종료 fade-out 개선까지 반영한 최종 상태의 코드 워크스루다.
1. 무엇이 바뀌었나 — canvas 합성으로의 전환
선행 구현은 talking <video>의 인라인 opacity를 직접 조작했다. 본 PR은 그 위에 전용 <canvas>를 덮고, 매 프레임 idle → talking 순으로 drawImage한 뒤 하단 페이드 그라데이션까지 그린다. PoC taeyang.html의 렌더 파이프라인을 그대로 옮긴 것이라 동일한 시각 품질을 보장한다.
① Canvas 합성
idle 위에 talking을 globalAlpha = dispOpacity로 합성. 하단 페이드까지 한 컨텍스트에서 그려 비디오 엘리먼트 깜빡임/합성 비용을 통제.
② 아바타별 on/off
Avatar.rmsLipSyncEnabled (DynamoDB). 아바타 관리 폼의 셀렉트로 토글, 목록에 ON/OFF 배지 표시.
③ 전역 공통 파라미터
9개 수치를 LanguageGuardSettings.avatarRms로 전역 튜닝. 채팅 프리셋 페이지의 "아바타 RMS 립싱크 파라미터" 섹션에서 조정 후 저장.
④ 발화 종료 fade-out
표시 opacity를 별도 이징(opacityRiseEase/opacityFallEase)으로 수렴. 종료 시 RMS 느린 감쇠에 맡기지 않고 빠르게 닫음.
2. 데이터 배선 — AI 오디오 스트림이 캔버스에 닿기까지
RMS 분석 대상은 핑퐁이(AI)의 출력 오디오 스트림이다. 게스트는 OpenAI Realtime output을, 모니터는 relay된 스트림을 사용하며, 둘 다 공유 컴포넌트 MeetAvatar로 모인다.
Source
- Guest use-guest-page-session.ts:2062
aiSession.aiAudioStream노출 - Monitor monitor-media-display.tsx
relay된aiAudioStream
Layout 분기
- guest-layout-content.tsx
- session이면
session.aiAudioStream - 아니면 prop
audioStream - →
MeetAvatar(audioStream)
Avatar 컴포넌트
- meet-avatar.tsx:47
rmsEnabled판정useAvatarRms({...})- 활성 시
<canvas>마운트
RMS 훅
- use-avatar-rms.ts:65
- 공유
AudioContext rAF합성 루프- canvas에 직접 렌더
// 데이터 흐름 (리렌더 없이 ref + rAF로만 동작)
Guest aiSession.aiAudioStream (OpenAI Realtime output) ─┐
Monitor relay aiAudioStream (mediasoup) ─┘
│
GuestLayoutContent(audioStream)
│
MeetAvatar(audioStream) ── rmsEnabled? ──▶ <canvas/> 마운트
│
useAvatarRms({ audioStream, canvasRef, idleVideoRef, talkingVideoRef, enabled, params })
│
AnalyserNode → RMS → requestAnimationFrame(loop) → canvas.drawImage
3. on/off 결정 트리 — 언제 RMS가 켜지나
meet-avatar.tsx:47 에서 rmsEnabled는 4개 조건의 AND다. 하나라도 거짓이면 canvas를 마운트하지 않고, 기존 CSS talkingVideoVisible 토글로 폴백한다(하위 호환).
rmsLipSyncEnabled — use-avatar-video.ts:151 가 avatar?.rmsLipSyncEnabled ?? false 반환!!props.audioStream — AI 출력 스트림이 배선됨!!currentIdleVideo!!currentTalkingVideormsEnabled = true → <canvas> 마운트 + useAvatarRms(enabled:true) 루프 시작전역 on/off는 없다. 커밋 id-051에서 의도적으로 제거됐다. 전역 avatarRms는 켜고 끄는 스위치가 아니라 켜진 아바타들에 공통 적용되는 수치 묶음일 뿐이다. ON/OFF 판단은 오직 아바타별 플래그가 한다.
4. 핵심 루프 — requestAnimationFrame 한 프레임의 일생
모든 실시간 로직은 use-avatar-rms.ts 의 loop() 한 함수에 있다. 매 프레임 아래 6단계를 순서대로 수행한다. 파라미터는 paramsRef로 읽어 튜닝 중에도 루프 재시작 없이 즉시 반영된다.
RMS 계산 — speech band 주파수 에너지
use-avatar-rms.ts:163 · analyser.getByteFrequencyData
300–3,500 Hz bin만 합산해 RMS 산출. 단, audioCtx.state==="running" & 트랙이 live & !muted 일 때만. 아니면 rawRMS = 0 → idle만 표시.
비대칭 평활화 — attack 빠르게 / release 느리게
use-avatar-rms.ts:174
alpha = rawRMS > smoothRMS ? p.attack : p.release → smoothRMS += alpha*(rawRMS - smoothRMS). 발화 시작엔 즉각 반응, 종료엔 천천히 감쇠.
talking 재생/정지 — 히스테리시스
use-avatar-rms.ts:177
진입은 smoothRMS ≥ rmsThr에서 talkVid.play(), 이탈은 더 낮은 rmsThr × 0.4에서 pause(). 임계값 근처 떨림(채터링) 방지.
재생속도 변조 — 음량에 따라 입 빠르기
use-avatar-rms.ts:186
playbackRate = playbackMin + min(smoothRMS/rmsFull, 1) × playbackRange. 기본값 기준 0.65x ~ 1.40x.
표시 opacity — 목표값 + 이징 (발화 종료 fade-out 핵심)
use-avatar-rms.ts:192
발화 중일 때만 target = min(smoothRMS/(rmsFull×opacityFullRatio), 1), 끝나면 0. 상승엔 opacityRiseEase, 하강엔 opacityFallEase로 dispOpacity를 수렴시킨다(<0.004면 0 스냅).
Canvas 합성 — idle → talking → 하단 페이드
use-avatar-rms.ts:204 · ctx.drawImage × 2 + createLinearGradient
globalAlpha=1로 idle, dispOpacity>0.015면 globalAlpha=dispOpacity로 talking을 덮고, 하단 78% 지점부터 어두운 그라데이션으로 입/턱 경계를 가린다. 끝에서 requestAnimationFrame(loop) 재귀.
핵심 코드 (요약 발췌)
const loop = () => {
if (cancelled) return;
const p = paramsRef.current; // 튜닝 즉시 반영 (deps 제외)
// 1) RMS — 트랙이 살아있고 음소거 아닐 때만 계산
let rawRMS = 0;
if (analyser && audioCtx?.state === "running" && audioTrack?.readyState === "live" && !audioTrack.muted) {
analyser.getByteFrequencyData(freqBuf);
/* loBin..hiBin 제곱합 → sqrt(mean) */
}
// 2) 비대칭 평활화
const alpha = rawRMS > smoothRMS ? p.attack : p.release;
smoothRMS += alpha * (rawRMS - smoothRMS);
// 3) 히스테리시스 재생/정지
if (smoothRMS >= p.rmsThr && !isTalking) { isTalking = true; talkVid.play(); }
else if (isTalking && smoothRMS < p.rmsThr * 0.4) { isTalking = false; talkVid.pause(); }
if (isTalking) talkVid.playbackRate = p.playbackMin + Math.min(smoothRMS / p.rmsFull, 1) * p.playbackRange;
// 5) 목표 opacity + 이징 (발화 종료 시 빠르게 닫힘)
const speaking = smoothRMS >= p.rmsThr;
const targetOpacity = (isTalking && speaking && talkVid.readyState >= 2)
? Math.min(smoothRMS / (p.rmsFull * p.opacityFullRatio), 1) : 0;
const ease = targetOpacity > dispOpacity ? p.opacityRiseEase : p.opacityFallEase;
dispOpacity += ease * (targetOpacity - dispOpacity);
if (dispOpacity < 0.004) dispOpacity = 0;
// 6) canvas 합성: idle → talking(alpha) → 하단 페이드
ctx.globalAlpha = 1; ctx.drawImage(idleVid, 0, 0, CW, CH);
if (dispOpacity > 0.015) { ctx.globalAlpha = dispOpacity; ctx.drawImage(talkVid, 0, 0, CW, CH); }
/* 하단 linear-gradient fill */
rafId = requestAnimationFrame(loop); // 다음 프레임
};
5. 전역 파라미터 9종 — 기본값 · clamp · 의미
기본값은 PoC taeyang.html 검증값(language-guard-settings.types.ts AVATAR_RMS_DEFAULTS). 입력 시 use-language-guard-settings-store.ts:201 clampAvatarRms가 범위를 강제한다.
| 파라미터 | 기본값 | clamp | 역할 |
|---|---|---|---|
attack | 0.45 | 0.05–1 | 소리 시작 반응 속도(α). 클수록 빠름 |
release | 0.07 | 0.01–1 | 소리 종료 감쇠 속도(α). 작을수록 느림 |
rmsThr | 0.038 | 0–0.3 | talking 진입 임계값 (이탈은 ×0.4) |
rmsFull | 0.20 | 0.05–1 | opacity/재생속도 100% 기준 RMS |
opacityFullRatio | 0.45 | 0.1–1 | opacity = min(RMS/(rmsFull×ratio), 1) |
playbackMin | 0.65 | 0.3–1 | talking 최소 재생 배속 |
playbackRange | 0.75 | 0–1.5 | 최대 = min + range |
opacityRiseEase | 0.60 | 0.05–1 | 페이드인 속도(상승 이징). 클수록 빠름 |
opacityFallEase | 0.32 | 0.05–1 | 페이드아웃 속도(하강 이징). 발화 종료 닫힘 속도 |
아래 고정 상수는 UI에 노출하지 않고 코드에 박혀 있다(taeyang 동일): FFT_SIZE=2048, ANALYSER_SMOOTHING=0.2, speech band 300–3500Hz, 하단 페이드 FADE_TOP_RATIO=0.78.
6. 저장 흐름 — 두 축, 두 경로
아바타별 on/off
- avatar-form.tsx 셀렉트 → POST/PUT
- api/avatars/route.ts 가
rmsLipSyncEnabled수용 - db-queries.ts:1070
updateAvatar가 DynamoDB 필드 set - 목록 배지: avatar-list.tsx ON/OFF
전역 파라미터
- avatar-rms-section.tsx 입력 →
updateDraftAvatarRms - store
draft.avatarRms(clamp 적용) - 저장 버튼 → save → DynamoDB
- db-queries.ts:2764
getLanguageGuardSettings가 로드 시{...AVATAR_RMS_DEFAULTS, ...loaded}머지 → 누락 필드 안전
draft 버퍼 패턴. 파라미터는 draft.avatarRms에서만 편집되고 저장 버튼 전까지 적용되지 않는다. 실제 렌더에 쓰이는 값은 적용된 state.avatarRms (meet-avatar.tsx:46 구독). payloadEquals가 9개 필드를 비교해 dirty 여부를 판정한다.
7. 변경 파일 한눈에
| 파일 | 변경 |
|---|---|
| hooks/use-avatar-rms.ts (신규) | 공유 AudioContext + rAF canvas 합성 루프. RMS·히스테리시스·재생속도·opacity 이징·하단 페이드 전부. |
| components/sections/avatar-rms-section.tsx (신규) | 전역 9개 파라미터 튜닝 UI(number 입력 + hint + 초기화). |
| shared/ui/meet-avatar.tsx | audioStream prop, rmsEnabled 판정, useAvatarRms 연결, <canvas> 마운트. |
| hooks/use-avatar-video.ts | 반환값에 rmsLipSyncEnabled 추가. |
| shared/ui/guest-layout-content.tsx | session/prop 양 분기에서 audioStream 추출 → MeetAvatar 전달. |
| shared/ui/monitor-media-display.tsx | 카드뷰·포커스뷰 모두 relay aiAudioStream 전달. |
| entities/.../use-guest-page-session.ts | return에 aiAudioStream 노출. |
| stores/use-language-guard-settings-store.ts | avatarRms 상태/draft/clamp/reset/equals/머지. |
| lib/db-queries.ts | updateAvatar 필드 set, settings 로드 시 기본값 머지. |
| app/api/avatars/route.ts | POST에서 rmsLipSyncEnabled 수용. |
| types/db/avatar.types.ts · .../language-guard-settings.types.ts | rmsLipSyncEnabled / AvatarRmsSettings + AVATAR_RMS_DEFAULTS 정의. |
| components/sections/avatar-form.tsx · avatar-list.tsx · components/pages/avatar.tsx · chat-preset.tsx | 아바타별 토글 UI, 목록 배지, 폼 초기화, 프리셋 페이지 섹션 삽입. |
8. 함정 / 주의
공유 AudioContext & autoplay 정책. 모듈 레벨 sharedAudioContext 1개를 lazy 재사용해 카드뷰 다중 아바타에서 컨텍스트 폭증을 막는다. 단 iOS Safari는 user gesture 전 suspended일 수 있어 루프 진입마다 resume()을 시도한다. state!=="running"인 동안 RMS는 0이라 idle만 보인다.
params는 deps에서 의도적으로 제외. useEffect deps는 [enabled, audioStream, canvasRef, idleVideoRef, talkingVideoRef]뿐. params는 paramsRef로 주입해 튜닝 시 루프를 재시작하지 않는다. (eslint-disable react-hooks/exhaustive-deps 의도적)
canvas는 alpha:false. 매 프레임 idle로 전체를 다시 칠하므로 투명 배경이 필요 없다. talking은 readyState>=2 & dispOpacity>0.015일 때만 덮는다.
적용 범위. V2 Guest(/client-guest)와 Monitor 대시보드(카드뷰·포커스뷰)만 해당. V1 레거시 /guest는 별도 컴포넌트라 범위 밖. 모니터의 relay 오디오 지터가 RMS 타이밍에 영향을 줄 수 있어 rmsThr 재튜닝 여지가 있다.