#!/usr/bin/env node /** * check-avatar-lipsync — 아바타 v3 발화층(립싱크) 데이터 정합성 검사. * docs/decisions/avatar-expression-engine-v3.md §5.4(발화층) "검증" 항목을 값·코드에 대해 검증한다. * engine 데이터 파일은 순수 TS라 esbuild로 node ESM으로 번들해(check-avatar-presets.mjs 패턴) * 동적 import로 값을 읽는다. 임시 산출물은 끝나면 지운다. */ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { build } from "esbuild"; const appRoot = process.cwd(); const engineDir = path.join(appRoot, "src", "components", "avatar", "engine"); const ENTRY_FILES = ["lipsync.ts", "speechEnvelope.ts", "demeanorDefaults.ts", "coSpeech.ts", "engine.ts"]; const failures = []; function fail(message) { failures.push(message); } async function loadModules() { const tempDir = await mkdtemp(path.join(os.tmpdir(), "vignette-avatar-lipsync-check-")); try { await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ type: "module" }), "utf8"); await build({ entryPoints: ENTRY_FILES.map((f) => path.join(engineDir, f)), outdir: tempDir, bundle: true, platform: "node", format: "esm", logLevel: "silent", }); const modules = {}; for (const f of ENTRY_FILES) { const outFile = path.join(tempDir, f.replace(/\.ts$/, ".js")); const url = `${pathToFileURL(outFile).href}?t=${Date.now()}`; modules[f] = await import(url); } return modules; } finally { await rm(tempDir, { recursive: true, force: true }); } } function nonSilentSequence(seq) { return seq.filter((v) => v !== "X"); } /* (a) 분해·매핑 표본(§5.4 검증: "음"은 끝이 M, "우리"는 U→I, "봐요"는 M→U→A→I→O). */ function checkVisemeSamples(buildSpeechTimeline, visemeSequence) { const cases = [ ["음", ["EU", "M"]], ["우리", ["U", "I"]], ["봐요", ["M", "U", "A", "I", "O"]], ]; for (const [text, expected] of cases) { const timeline = buildSpeechTimeline({ text, syllablesPerSec: 5.0 }); const seq = nonSilentSequence(visemeSequence(timeline)); if (JSON.stringify(seq) !== JSON.stringify(expected)) { fail(`비짐 표본 "${text}": 기대 [${expected.join(",")}], 실제 [${seq.join(",")}]`); } } } /* (b) 최소 유지 60ms, M·X 밖에서는 open > 0(§5.4 검증). */ function checkTimingInvariants(buildSpeechTimeline) { const timeline = buildSpeechTimeline({ text: "음… 그냥 요즘 잠을 잘 못 자요.", syllablesPerSec: 5.0 }); for (let i = 1; i < timeline.keys.length; i++) { const gap = timeline.keys[i].atMs - timeline.keys[i - 1].atMs; if (gap < 59.99) fail(`최소 유지: 키 ${i - 1}→${i} 간격 ${gap.toFixed(2)}ms < 60ms`); } for (const key of timeline.keys) { if (key.viseme === "M" || key.viseme === "X") continue; if (!(key.shape.open > 0)) fail(`open>0 불변량: 비짐 ${key.viseme}의 open=${key.shape.open}`); } } /* (c) 텍스트 타이밍에서 syllablesPerSec와 발화 길이의 일치(받침·휴지 없는 표본으로 가중치를 1.0 고정). */ function checkTextTimingRate(buildSpeechTimeline) { const text = "가나다라마바사"; const syllablesPerSec = 5.0; const timeline = buildSpeechTimeline({ text, syllablesPerSec }); const expectedMs = 7 * (1000 / syllablesPerSec); if (Math.abs(timeline.totalDurationMs - expectedMs) > 0.01) { fail(`텍스트 타이밍: 기대 ${expectedMs}ms, 실제 ${timeline.totalDurationMs}ms`); } } /* (d) 합성 포락선(구 3개·무음 2개) 정렬: 구 경계 오차 ≤ 30ms(§5.4 검증). */ function checkAudioAlignment(buildSpeechTimeline) { const hopMs = 10; const totalHops = 150; const rms = new Float32Array(totalHops).fill(0.01); const loudSpans = [ [0, 20], [50, 70], [100, 120], ]; for (const [a, b] of loudSpans) for (let i = a; i < b; i++) rms[i] = 1.0; const sorted = Array.from(rms).sort((a, b) => a - b); const pct = (p) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1))]; const envelope = { hopMs, rms, p90: pct(0.9), p95: pct(0.95) }; const timeline = buildSpeechTimeline({ text: "가. 나. 다", syllablesPerSec: 5.0, envelope }); const expected = loudSpans.map(([a, b]) => ({ startMs: a * hopMs, endMs: b * hopMs })); const windows = timeline.phraseWindows ?? []; if (windows.length !== expected.length) { fail(`구 경계: 기대 구 ${expected.length}개, 실제 ${windows.length}개`); return; } windows.forEach((w, i) => { const startErr = Math.abs(w.startMs - expected[i].startMs); const endErr = Math.abs(w.endMs - expected[i].endMs); if (startErr > 30) fail(`구 경계 ${i}: 시작 오차 ${startErr}ms > 30ms`); if (endErr > 30) fail(`구 경계 ${i}: 끝 오차 ${endErr}ms > 30ms`); }); } /* ── §5.5 발화 동반층 검증 ─────────────────────────────────────────── */ /* (e) 같은 시드면 계획이 같다. */ function checkPlanDeterminism(buildSpeechTimeline, buildCoSpeechPlan) { const timeline = buildSpeechTimeline({ text: "음… 그냥 요즘 잠을 잘 못 자요. 정말 그렇게 생각하세요?", syllablesPerSec: 5.0 }); const style = { gesture: 0.55, gazeAvertProb: 0.7 }; const a = buildCoSpeechPlan(timeline, style, 7); const b = buildCoSpeechPlan(timeline, style, 7); if (JSON.stringify(a) !== JSON.stringify(b)) fail("계획 결정성: 같은 시드인데 계획이 다르다"); } /* (f) 강세 박 정점 = 모음 목표 − 80ms(±1ms). */ function checkStressPulsePeaks(buildSpeechTimeline, buildCoSpeechPlan) { const timeline = buildSpeechTimeline({ text: "음… 그냥 요즘 잠을 잘 못 자요. 정말 그렇게 생각하세요?", syllablesPerSec: 5.0 }); const plan = buildCoSpeechPlan(timeline, { gesture: 0.55, gazeAvertProb: 0.7 }, 7); const expectedPeaks = []; for (const ph of timeline.phrases) { for (const idx of ph.stressedIndices) { const vowelMs = ph.syllableTargetsMs[idx]; if (vowelMs !== undefined) expectedPeaks.push(vowelMs - 80); } } const actualPeaks = plan.stressPulses.map((p) => p.peakMs); if (actualPeaks.length !== expectedPeaks.length) { fail(`강세 박 개수: 기대 ${expectedPeaks.length}, 실제 ${actualPeaks.length}`); return; } expectedPeaks.forEach((exp, i) => { const err = Math.abs(actualPeaks[i] - exp); if (err > 1) fail(`강세 박 ${i} 정점: 기대 ${exp}ms, 실제 ${actualPeaks[i]}ms(오차 ${err.toFixed(2)}ms)`); }); } /* (g) 질문 구에서 headRoll·headPitch가 들린다. */ function checkQuestionLift(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech) { const timeline = buildSpeechTimeline({ text: "정말 그렇게 생각하세요?", syllablesPerSec: 5.0 }); const plan = buildCoSpeechPlan(timeline, { gesture: 0.55, gazeAvertProb: 0.7 }, 7); const ph = timeline.phrases[0]; const holdMs = ph.endMs + 150; /* 300ms 유지 구간 중간 */ const sample = sampleCoSpeech(plan, holdMs, holdMs - 16, undefined); if (!(sample.delta.headPitch > 0)) fail(`질문 구 headPitch: ${holdMs}ms에 ${sample.delta.headPitch}(양수 기대)`); if (!(Math.abs(sample.delta.headRoll) > 0)) fail(`질문 구 headRoll: ${holdMs}ms에 ${sample.delta.headRoll}(0이 아닌 값 기대)`); } /* (h) 발화 마지막 400ms에는 시선 회피가 없다. */ function checkNoGazeAvertInTail(buildSpeechTimeline, buildCoSpeechPlan) { const timeline = buildSpeechTimeline({ text: "가나다라마바사아자차카, 나", syllablesPerSec: 5.0 }); const lastPhrase = timeline.phrases[timeline.phrases.length - 1]; if (!(lastPhrase.startMs >= timeline.totalDurationMs - 400)) { fail("시선 회피 표본 설계: 마지막 구가 발화 마지막 400ms 안에서 시작하지 않는다(표본 텍스트 재조정 필요)"); return; } const plan = buildCoSpeechPlan(timeline, { gesture: 1, gazeAvertProb: 1 }, 3); if (plan.gazePulses.length !== timeline.phrases.length - 1) { fail(`시선 회피 발동 수: 기대 ${timeline.phrases.length - 1}(마지막 구 제외), 실제 ${plan.gazePulses.length}`); } for (const g of plan.gazePulses) { const startMs = g.breakpoints[0].t; if (startMs >= timeline.totalDurationMs - 400) fail(`시선 회피가 발화 마지막 400ms 안(${startMs}ms)에서 시작했다`); } } /* (h-2) 평서 구 끝 끄덕임 headPitch < 0. */ function checkStatementNodPitch(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech) { const timeline = buildSpeechTimeline({ text: "그냥요.", syllablesPerSec: 5.0 }); const plan = buildCoSpeechPlan(timeline, { gesture: 0.55, gazeAvertProb: 0.7 }, 7); const ph = timeline.phrases[0]; const lastTargetMs = ph.syllableTargetsMs[ph.syllableTargetsMs.length - 1]; const sample = sampleCoSpeech(plan, lastTargetMs, lastTargetMs - 16, undefined); if (!(sample.delta.headPitch < 0)) fail(`평서 구 끝 끄덕임: ${lastTargetMs}ms에 headPitch=${sample.delta.headPitch}(음수 기대)`); } /* (h-3) 엔진 통과: speaking 중 강세 박은 스프링 없이 그대로 통과해 정점 시각·크기를 지킨다 (팀 리드 수정 요청, 2026-10-01). reduced motion 끔, 표정 neutral, 고정 시계 10ms 간격. */ function checkEngineStressPulsePassthrough(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech, demeanorFor, createAvatarEngine) { const timeline = buildSpeechTimeline({ text: "그냥요.", syllablesPerSec: 5.0 }); const style = { gesture: 0.55, gazeAvertProb: 0.7 }; const plan = buildCoSpeechPlan(timeline, style, 7); const stress = plan.stressPulses[0]; if (!stress) { fail("엔진 통과 검증: 강세 박이 없다"); return; } const demeanor = demeanorFor("P1"); const engine = createAvatarEngine({ demeanor, seed: 1, reducedMotion: false }); engine.setState("speaking", 0); engine.setSurface("neutral", 0); const scanStart = Math.max(0, stress.peakMs - stress.riseMs - 50); const scanEnd = stress.peakMs + stress.fallMs + 50; const stepMs = 10; const baseline = engine.evaluate(scanStart); const baseHeadPitch = baseline.headPitch; let prevLocalMs = scanStart; let bestT = null; let bestDelta = 0; for (let t = scanStart; t <= scanEnd; t += stepMs) { const sample = sampleCoSpeech(plan, t, prevLocalMs); engine.setSpeechMotion(sample.delta); prevLocalMs = t; const v = engine.evaluate(t); const coHeadPitch = v.headPitch - baseHeadPitch; if (bestT === null || coHeadPitch < bestDelta) { bestDelta = coHeadPitch; bestT = t; } } const timeErr = Math.abs(bestT - stress.peakMs); if (timeErr > 10) fail(`엔진 통과: 강세 박 정점 시각 오차 ${timeErr}ms > 10ms(계획 ${stress.peakMs}ms, 엔진 ${bestT}ms)`); const expectedMag = Math.abs(stress.headPitch); const actualMag = Math.abs(bestDelta); if (actualMag < expectedMag * 0.9) { fail(`엔진 통과: 강세 박 크기 ${actualMag.toFixed(4)} < 계획 값의 90%(${(expectedMag * 0.9).toFixed(4)})`); } } /* (i) reduced motion에서는 엔진 delta가 0. */ function checkReducedMotionZero(demeanorFor, createAvatarEngine) { const demeanor = demeanorFor("P1"); const engine = createAvatarEngine({ demeanor, seed: 1, reducedMotion: true }); engine.setState("speaking", 0); engine.setSpeechMotion({ headYaw: 0.9, headRoll: 0.9, browLY: 0.9, browRY: 0.9, gazeX: 0.9 }); const v = engine.evaluate(50); /* P1 basePose는 이 채널들을 건드리지 않아 중립값이 정확히 0이다(demeanorDefaults.ts 기준). */ for (const id of ["headYaw", "headRoll", "browLY", "browRY", "gazeX"]) { if (v[id] !== 0) fail(`reduced motion delta: ${id} 기대 0, 실제 ${v[id]}(setSpeechMotion이 반영되면 안 된다)`); } } const modules = await loadModules(); const { buildSpeechTimeline, visemeSequence } = modules["lipsync.ts"]; const { demeanorFor } = modules["demeanorDefaults.ts"]; const { buildCoSpeechPlan, sampleCoSpeech } = modules["coSpeech.ts"]; const { createAvatarEngine } = modules["engine.ts"]; const p1Rate = demeanorFor("P1").speech.syllablesPerSec; if (p1Rate !== 5.0) fail(`P1 syllablesPerSec 기대 5.0, 실제 ${p1Rate}`); checkVisemeSamples(buildSpeechTimeline, visemeSequence); checkTimingInvariants(buildSpeechTimeline); checkTextTimingRate(buildSpeechTimeline); checkAudioAlignment(buildSpeechTimeline); checkPlanDeterminism(buildSpeechTimeline, buildCoSpeechPlan); checkStressPulsePeaks(buildSpeechTimeline, buildCoSpeechPlan); checkQuestionLift(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech); checkNoGazeAvertInTail(buildSpeechTimeline, buildCoSpeechPlan); checkStatementNodPitch(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech); checkEngineStressPulsePassthrough(buildSpeechTimeline, buildCoSpeechPlan, sampleCoSpeech, demeanorFor, createAvatarEngine); checkReducedMotionZero(demeanorFor, createAvatarEngine); if (failures.length > 0) { for (const message of failures) console.error(`FAIL: ${message}`); console.error(`check-avatar-lipsync: 실패 ${failures.length}건`); process.exit(1); } console.log( "check-avatar-lipsync: 통과 (비짐 표본 3건, 최소 유지·open>0 불변량, 텍스트 발화 속도 일치, 구 경계 정렬 ≤30ms, " + "발화 동반층 결정성·강세 박 정점·질문 들림·시선 회피 꼬리 금지·평서 구 끝 끄덕임·엔진 통과·reduced motion 0)", );