vignette/apps/web/scripts/check-avatar-lipsync.mjs
Yun Chan 85bd079d18 아바타 v3 표현 엔진·리노컷 렌더러·립싱크와 Lab
- 표현 엔진: 채널 34개(mouthRound 추가), 표정 28종·강도 곡선·반응 클립 26종·지문 cue, 미세표정 누설
- 발화층: 한글 자모 비짐 9종, TTS 오디오 선분석 정렬, 60ms 앞당김·모음 간 비닫힘
- 발화 동반층: 억양·강세에 맞춘 고개 표류·끄덕임·질문 갸웃·들숨·눈썹 박·시선 회피·쉼 깜빡임
- P1 서연 리노컷 렌더러: 원화 픽셀 입술 띠 변형, 하관 띠 변형(턱·코 들썩), 볼 불룩, 작은 크기 선 보정, 모티프·배경 색면
- /dev/avatar-lab, check:avatar-presets·check:avatar-lipsync, avatar-lab E2E 19개
2026-10-01 09:58:00 +09:00

288 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)",
);