아바타 v3 표현 엔진·리노컷 렌더러·립싱크와 Lab

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

View file

@ -0,0 +1,316 @@
/* =====================================================================
avatar-lab.spec.ts — 아바타 v3 표현 엔진 dev Lab(/dev/avatar-lab) 검증 스펙.
무인증 dev 페이지라 auth mock이 필요 없다(AvatarPreview 패턴과 동일).
===================================================================== */
import { expect, test } from "@playwright/test";
function channelCell(page: import("@playwright/test").Page, channel: string) {
return page.locator(`tr[data-channel="${channel}"] td[data-value]`);
}
test.describe("avatar v3 lab", () => {
/* 이 페이지는 무인증 dev 라우트지만, AuthProvider가 마운트 시 항상 /auth/me를
호출한다. 실 백엔드에 세션이 없으면 401이 그대로 콘솔 에러로 찍혀
"콘솔 error 0" 판정을 이 페이지와 무관한 원인으로 깨뜨린다
(avatar-expression-lab.spec.ts / admin-affect.spec.ts와 같은 mock 패턴). */
test.beforeEach(async ({ page }) => {
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "avatar-lab-learner",
email: "learner@hs.ac.kr",
display_name: "Avatar Lab Learner",
role: "learner",
cohort_ids: [],
onboarding_completed_at: Math.floor(Date.now() / 1000),
}),
}),
);
});
test("콘솔 에러 없이 로드된다", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
await page.goto("/dev/avatar-lab?seed=1");
await expect(page.locator('[data-avatar-lab="true"]')).toBeVisible();
await page.waitForTimeout(300);
expect(errors).toEqual([]);
});
test("표정 sad 강도 1 선택 후 채널이 뚜렷해진다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
await page.locator('[data-testid="expression-sad"]').click();
const intensitySlider = page.locator(".al__intensity input[type='range']");
await intensitySlider.focus();
await intensitySlider.press("End");
const browLAngleCell = channelCell(page, "browLAngle");
const mouthFormCell = channelCell(page, "mouthForm");
await expect.poll(async () => Number(await browLAngleCell.innerText()), { timeout: 3000 }).toBeGreaterThan(0.3);
await expect.poll(async () => Number(await mouthFormCell.innerText()), { timeout: 3000 }).toBeLessThan(-0.2);
});
test("nod_small 클릭 시 headPitch가 일시적으로 낮아진다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
const headPitchCell = channelCell(page, "headPitch");
const before = Number(await headPitchCell.innerText());
await page.locator('[data-testid="clip-nod_small"]').click();
await expect
.poll(async () => before - Number(await headPitchCell.innerText()), { timeout: 1500, intervals: [50] })
.toBeGreaterThanOrEqual(0.05);
});
test("괄호 지문이 cue로 파싱되고 미대응 지문이 표시된다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
await page.locator('[data-testid="speech-text"]').fill("(한숨) 그냥요. (작은 목소리로)");
await page.locator('[data-testid="play-speech"]').click();
const firstCue = page.locator('[data-testid="parsed-cues"] li').first();
await expect(firstCue).toHaveAttribute("data-clip", "sigh");
await expect(firstCue).toHaveAttribute("data-anchor", "pre");
await expect(firstCue).toHaveAttribute("data-source", "direction");
await expect(page.locator('[data-testid="unmatched-directions"] li')).toHaveText("작은 목소리로");
});
test("누설 테스트 시 browLAngle이 일시적으로 커졌다가 다시 작아진다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
const browLAngleCell = channelCell(page, "browLAngle");
const before = Number(await browLAngleCell.innerText());
await page.locator('[data-testid="play-leak-test"]').click();
await expect
.poll(async () => Number(await browLAngleCell.innerText()) - before, { timeout: 1500, intervals: [50] })
.toBeGreaterThanOrEqual(0.15);
await page.waitForTimeout(1500);
const after = Number(await browLAngleCell.innerText());
expect(after - before).toBeLessThan(0.15);
});
test("reduced motion 토글 시 반응 클립이 비활성화된다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
await page.locator('[data-testid="reduced-motion-toggle"]').click();
await page.waitForTimeout(150);
await expect(page.locator('[data-testid="active-clips"] li')).toHaveCount(0);
await page.locator('[data-testid="clip-nod_small"]').click();
await page.waitForTimeout(200);
await expect(page.locator('[data-testid="active-clips"] li')).toHaveCount(0);
});
/* 발화층(립싱크) — 결정문 §5.4. 비짐별 입 모양이 채널에 실제로 합성되는지 채널 표로 확인한다. */
test("'우우우우' 발화 중 mouthRound이 커진다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
await page.locator('[data-testid="speech-text"]').fill("우우우우");
await page.locator('[data-testid="play-speech"]').click();
const mouthRoundCell = channelCell(page, "mouthRound");
await expect
.poll(async () => Number(await mouthRoundCell.innerText()), { timeout: 3000, intervals: [50] })
.toBeGreaterThan(0.4);
});
test("'아아아아' 발화 중 mouthOpen이 커진다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
await page.locator('[data-testid="speech-text"]').fill("아아아아");
await page.locator('[data-testid="play-speech"]').click();
const mouthOpenCell = channelCell(page, "mouthOpen");
await expect
.poll(async () => Number(await mouthOpenCell.innerText()), { timeout: 3000, intervals: [50] })
.toBeGreaterThan(0.3);
});
test("'음 음 음' 발화 중 mouthPress가 커진다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
await page.locator('[data-testid="speech-text"]').fill("음 음 음");
await page.locator('[data-testid="play-speech"]').click();
const mouthPressCell = channelCell(page, "mouthPress");
await expect
.poll(async () => Number(await mouthPressCell.innerText()), { timeout: 3000, intervals: [50] })
.toBeGreaterThan(0.2);
});
/* 발화 동반층(§5.5) — 말할 때 머리·몸·눈썹·시선·깜빡임. */
test("'정말 그렇게 생각하세요?' 발화 중 headRoll 절댓값이 0.03을 넘는 순간이 있다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
await page.locator('[data-testid="speech-text"]').fill("정말 그렇게 생각하세요?");
await page.locator('[data-testid="play-speech"]').click();
const headRollCell = channelCell(page, "headRoll");
await expect
.poll(async () => Math.abs(Number(await headRollCell.innerText())), { timeout: 4000, intervals: [50] })
.toBeGreaterThan(0.03);
});
test("평서문 발화 중 headPitch가 -0.05 아래로 내려가는 순간이 있다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
await page.locator('[data-testid="speech-text"]').fill("그냥요.");
await page.locator('[data-testid="play-speech"]').click();
const headPitchCell = channelCell(page, "headPitch");
await expect
.poll(async () => Number(await headPitchCell.innerText()), { timeout: 4000, intervals: [50] })
.toBeLessThan(-0.05);
});
test("reduced motion에서는 발화 중 headYaw가 기본 자세에서 벗어나지 않는다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
const headYawCell = channelCell(page, "headYaw");
const baseline = Number(await headYawCell.innerText());
await page.locator('[data-testid="reduced-motion-toggle"]').click();
await page.locator('[data-testid="speech-text"]').fill("정말 그렇게 생각하세요?");
await page.locator('[data-testid="play-speech"]').click();
await page.waitForTimeout(500);
expect(Number(await headYawCell.innerText())).toBeCloseTo(baseline, 2);
await page.waitForTimeout(800);
expect(Number(await headYawCell.innerText())).toBeCloseTo(baseline, 2);
});
/* 2단계-B — 리노컷 렌더러(§8.4). 기본 렌더러는 linocut(P1)이라 위 6개는 그대로
통과한다(채널 미터·클립·지문 파서는 렌더러와 무관하게 동작한다). */
test("linocut 메인 루트가 ready가 되고 레이어 이미지 요청이 전부 200이다", async ({ page }) => {
const imageStatuses: number[] = [];
page.on("response", (res) => {
if (/\/avatar\/v3\/p1\/.*\.webp$/.test(res.url())) imageStatuses.push(res.status());
});
await page.goto("/dev/avatar-lab?seed=1");
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
expect(imageStatuses.length).toBeGreaterThanOrEqual(3);
expect(imageStatuses.every((s) => s === 200)).toBe(true);
});
test("joy 전환 후 mouth-line d가 바뀌고 grief에서 tear 부위가 보인다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
const mouthLine = mainSvg.locator('[data-part="mouth-line"]');
const neutralD = await mouthLine.getAttribute("d");
await page.locator('[data-testid="expression-joy"]').click();
const intensitySlider = page.locator(".al__intensity input[type='range']");
await intensitySlider.focus();
await intensitySlider.press("End");
await expect.poll(async () => mouthLine.getAttribute("d"), { timeout: 2000 }).not.toBe(neutralD);
await page.locator('[data-testid="expression-grief"]').click();
await expect
.poll(async () => Number(await mainSvg.locator('[data-part="tear-left"]').getAttribute("opacity")), {
timeout: 2000,
})
.toBeGreaterThan(0);
});
test("sad 선택 시 배경·모티프 그룹이 negative가 된다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
await page.locator('[data-testid="expression-sad"]').click();
await expect(mainSvg).toHaveAttribute("data-backdrop-group", "negative");
await expect(page.locator('[data-motif="bud-weather"]')).toHaveAttribute("data-motif-weather", "negative");
});
test("피드백 모드에 따라 모티프 봉오리 개방도가 바뀐다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
const motif = page.locator('[data-motif="bud-weather"]');
const feedbackSelect = page.locator('[data-testid="feedback-mode"]');
await feedbackSelect.selectOption("immersive");
await expect(motif).toHaveAttribute("data-motif-bud", "static");
await feedbackSelect.selectOption("coached");
const opennessSlider = page.locator('[data-testid="openness-slider"]');
await opennessSlider.focus();
await opennessSlider.press("End");
await expect(motif).toHaveAttribute("data-motif-bud", "1.00");
});
test("공연을 연달아 재생하면 이전 공연의 미발동 cue가 취소된다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
await page.locator('[data-testid="pause-toggle"]').click();
await page.locator('[data-testid="speech-text"]').fill("(한숨) 첫 번째 문장이다");
await page.locator('[data-testid="play-speech"]').click();
await page.waitForTimeout(150);
const firstScheduled = await page.locator('[data-testid="scheduled-cues"] li').allTextContents();
expect(firstScheduled.some((t) => t.includes("sigh"))).toBe(true);
await page.locator('[data-testid="speech-text"]').fill("(시선 피함) 두 번째는 다른 지문이다");
await page.locator('[data-testid="play-speech"]').click();
await page.waitForTimeout(150);
const secondScheduled = await page.locator('[data-testid="scheduled-cues"] li').allTextContents();
expect(secondScheduled.some((t) => t.includes("sigh"))).toBe(false);
expect(secondScheduled.some((t) => t.includes("look_away_side"))).toBe(true);
});
test("reduced motion에서도 linocut 루트는 ready를 유지하고 모티프 애니메이션이 멈춘다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]');
await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 });
await page.locator('[data-testid="expression-sad"]').click();
await expect(page.locator('[data-motif="bud-weather"]')).toHaveAttribute("data-motif-weather", "negative");
await page.locator('[data-testid="reduced-motion-toggle"]').click();
await page.waitForTimeout(200);
await expect(mainSvg).toHaveAttribute("data-load-state", "ready");
const rainSprite = page.locator('[data-motif="bud-weather"] .linocut-motif__weather-negative').first();
const animationName = await rainSprite.evaluate((el) => getComputedStyle(el).animationName);
expect(animationName === "none" || animationName === "").toBe(true);
});
test("sad + immersive는 봉오리가 숙이고, coached + 개방도 0.8 + sad는 곧게 선다", async ({ page }) => {
await page.goto("/dev/avatar-lab?seed=1");
const motif = page.locator('[data-motif="bud-weather"]');
const feedbackSelect = page.locator('[data-testid="feedback-mode"]');
await feedbackSelect.selectOption("immersive");
await page.locator('[data-testid="expression-sad"]').click();
await expect(motif).toHaveAttribute("data-motif-pose", "droop");
await feedbackSelect.selectOption("coached");
const opennessSlider = page.locator('[data-testid="openness-slider"]');
await opennessSlider.evaluate((el) => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
setter.call(el, "0.8");
el.dispatchEvent(new Event("input", { bubbles: true }));
});
await expect(motif).toHaveAttribute("data-motif-pose", "upright");
});
});

View file

@ -17,6 +17,8 @@
"check:dead-code": "knip --include files,dependencies,unlisted,unresolved,binaries --treat-config-hints-as-errors",
"check:duplication": "jscpd src ../api/app --min-lines 8 --min-tokens 80 --ignore **/test_*.py,**/*_test.py,**/api.gen.ts,**/*.css,**/*.d.ts --threshold 0.05 --reporters console,threshold --no-tips",
"generate:live2d-assets": "node scripts/generate-live2d-assets.mjs",
"check:avatar-presets": "node scripts/check-avatar-presets.mjs",
"check:avatar-lipsync": "node scripts/check-avatar-lipsync.mjs",
"preview": "vite preview",
"typecheck": "tsc -b",
"lint": "npm run check:cosmetic-filter-safety && tsc -b",

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,288 @@
#!/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)",
);

View file

@ -0,0 +1,267 @@
#!/usr/bin/env node
/**
* check-avatar-presets — 아바타 v3 표현 엔진 데이터 정합성 검사.
* docs/decisions/avatar-expression-engine-v3.md §5.1(구분 가능성 불변량)·
* §6.1(클립 형식)·§7.3(지문 파서) 규칙을 값·코드에 대해 검증한다.
*
* engine 데이터 파일은 순수 TS라 esbuild로 각각 node ESM으로 번들해(임시 디렉터리,
* generate-live2d-assets.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 = [
"channels.ts",
"expressionPresets.ts",
"clipCatalog.ts",
"demeanorDefaults.ts",
"stageDirectionLexicon.ts",
"performance.ts",
];
/* 저작용 대칭 키 — channels.ts SYMMETRIC_KEYS 와 같은 쌍. 구분 가능성 불변량 계산에서
좌우를 평균 1개 채널로 접는다(결정문 §5.1). */
const SYMMETRIC_PAIRS = [
["eyeOpenL", "eyeOpenR"],
["eyeSmileL", "eyeSmileR"],
["browLY", "browRY"],
["browLAngle", "browRAngle"],
["browLX", "browRX"],
];
const failures = [];
function fail(message) {
failures.push(message);
}
async function loadModules() {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "vignette-avatar-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 effectiveChannelVector(delta, channelIds) {
const used = new Set(SYMMETRIC_PAIRS.flat());
const out = {};
for (const [l, r] of SYMMETRIC_PAIRS) {
const lv = delta[l] ?? 0;
const rv = delta[r] ?? 0;
out[`${l}/${r}`] = (lv + rv) / 2;
}
for (const id of channelIds) {
if (used.has(id)) continue;
out[id] = delta[id] ?? 0;
}
return out;
}
function l1Distance(a, b) {
let sum = 0;
let max = 0;
for (const key of Object.keys(a)) {
const d = Math.abs((a[key] ?? 0) - (b[key] ?? 0));
sum += d;
if (d > max) max = d;
}
return { sum, max };
}
/* (a) 구분 가능성 불변량: 378쌍 L1 ≥ 0.6, 최대 단일 채널 차이 ≥ 0.25. */
function checkDiscriminability(CHANNEL_IDS, EXPRESSION_PRESETS) {
const ids = Object.keys(EXPRESSION_PRESETS);
const vectors = new Map(ids.map((id) => [id, effectiveChannelVector(EXPRESSION_PRESETS[id], CHANNEL_IDS)]));
let pairCount = 0;
let minL1 = Infinity;
let minL1Pair = "";
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
pairCount++;
const { sum, max } = l1Distance(vectors.get(ids[i]), vectors.get(ids[j]));
if (sum < minL1) {
minL1 = sum;
minL1Pair = `${ids[i]}/${ids[j]}`;
}
if (sum < 0.6) fail(`구분 가능성: ${ids[i]}/${ids[j]} L1=${sum.toFixed(3)} < 0.6`);
if (max < 0.25) fail(`구분 가능성: ${ids[i]}/${ids[j]} 최대 단일 채널 차이=${max.toFixed(3)} < 0.25`);
}
}
const expectedPairs = (ids.length * (ids.length - 1)) / 2;
if (pairCount !== expectedPairs) fail(`구분 가능성: 쌍 개수 ${pairCount} != 기대 ${expectedPairs}`);
return { pairCount, minL1, minL1Pair };
}
/* (b) 모든 프리셋·클립·basePose 키가 CHANNEL_IDS에 속함. */
function checkChannelKeys(CHANNEL_IDS, EXPRESSION_PRESETS, REACTION_CLIPS, DEFAULT_DEMEANOR, personaDemeanors) {
const validIds = new Set(CHANNEL_IDS);
for (const [exprId, delta] of Object.entries(EXPRESSION_PRESETS)) {
for (const key of Object.keys(delta)) {
if (!validIds.has(key)) fail(`채널 키: 프리셋 ${exprId}의 ${key}가 CHANNEL_IDS에 없음`);
}
}
for (const [clipId, clip] of Object.entries(REACTION_CLIPS)) {
for (const key of Object.keys(clip.tracks)) {
if (!validIds.has(key)) fail(`채널 키: 클립 ${clipId}의 트랙 ${key}가 CHANNEL_IDS에 없음`);
}
}
const demeanors = [["DEFAULT", DEFAULT_DEMEANOR], ...personaDemeanors];
for (const [label, demeanor] of demeanors) {
for (const key of Object.keys(demeanor.basePose)) {
if (!validIds.has(key)) fail(`채널 키: demeanor ${label}의 basePose ${key}가 CHANNEL_IDS에 없음`);
}
}
}
/* (c)(d) 클립 키프레임 형식. */
function checkClipKeyframes(REACTION_CLIPS) {
for (const [clipId, clip] of Object.entries(REACTION_CLIPS)) {
if (clip.fadeInMs + clip.fadeOutMs > clip.durationMs) {
fail(`클립 ${clipId}: fadeInMs(${clip.fadeInMs})+fadeOutMs(${clip.fadeOutMs}) > durationMs(${clip.durationMs})`);
}
for (const [channelId, frames] of Object.entries(clip.tracks)) {
if (!frames || frames.length === 0) continue;
const [t0, v0] = frames[0];
if (t0 !== 0) fail(`클립 ${clipId}.${channelId}: 첫 키프레임 시각이 0이 아님(${t0})`);
if (v0 !== 0) fail(`클립 ${clipId}.${channelId}: 첫 키프레임 값이 0이 아님(${v0})`);
for (let i = 1; i < frames.length; i++) {
if (frames[i][0] <= frames[i - 1][0]) {
fail(`클립 ${clipId}.${channelId}: 시각이 오름차순이 아님(${frames[i - 1][0]} -> ${frames[i][0]})`);
}
}
const last = frames[frames.length - 1];
if (last[0] > clip.durationMs) {
fail(`클립 ${clipId}.${channelId}: 마지막 키프레임 시각(${last[0]}) > durationMs(${clip.durationMs})`);
}
if (last[1] !== 0 && clip.fadeOutMs < 300) {
fail(`클립 ${clipId}.${channelId}: 마지막 값(${last[1]})이 0이 아닌데 fadeOutMs(${clip.fadeOutMs}) < 300`);
}
}
}
}
/* (e) demeanor idleClips·lexicon의 clip id가 REACTION_CLIP_IDS에 존재. */
function checkClipReferences(REACTION_CLIP_IDS, DEFAULT_DEMEANOR, personaDemeanors, STAGE_DIRECTION_RULES) {
const validIds = new Set(REACTION_CLIP_IDS);
const demeanors = [["DEFAULT", DEFAULT_DEMEANOR], ...personaDemeanors];
for (const [label, demeanor] of demeanors) {
for (const rule of demeanor.idleClips) {
if (!validIds.has(rule.clip)) fail(`demeanor ${label}의 idleClips 참조 ${rule.clip}가 REACTION_CLIP_IDS에 없음`);
}
}
for (const rule of STAGE_DIRECTION_RULES) {
if (!validIds.has(rule.clip)) fail(`stageDirectionLexicon 규칙의 ${rule.clip}가 REACTION_CLIP_IDS에 없음`);
}
}
/* (f) 표본 지문 대응표(결정문 §7.3, 앵커 보정은 오케스트레이터 2026-09-30 정정). */
function checkStageDirectionSamples(parseStageDirections) {
const singleClipCases = [
["(한숨)", "sigh", undefined],
["(옅은 한숨)", "sigh", 0.6],
["(잠시 멈춤)", "look_down", undefined],
["(잠깐 침묵)", "silence_hold", undefined],
["(고개 살짝 돌림)", "look_away_side", undefined],
["(어깨 으쓱)", "shrug", undefined],
["(피식)", "scoff", undefined],
["(어색한 웃음)", "nervous_laugh", undefined],
["(긴장한 웃음)", "nervous_laugh", undefined],
["(머뭇)", "lip_press", undefined],
["(손톱 만지작)", "fidget_sway", undefined],
["(시선 피함)", "look_away_side", undefined],
["(쓴웃음)", "scoff", undefined],
];
for (const [text, expectedClip, expectedWeight] of singleClipCases) {
const { cues } = parseStageDirections(text);
if (cues.length !== 1 || cues[0].clip !== expectedClip) {
fail(`지문 "${text}": 기대 클립 ${expectedClip}, 실제 ${cues.map((c) => c.clip).join(",") || "(없음)"}`);
continue;
}
if (expectedWeight !== undefined && cues[0].weight !== expectedWeight) {
fail(`지문 "${text}": 기대 weight ${expectedWeight}, 실제 ${cues[0].weight}`);
}
}
const { cues: multi } = parseStageDirections("(한숨, 침묵 10초)");
const multiClips = multi.map((c) => c.clip).join(",");
if (multiClips !== "sigh,silence_hold") {
fail(`지문 "(한숨, 침묵 10초)": 기대 [sigh, silence_hold], 실제 [${multiClips}]`);
}
const unmatchedCases = ["(작은 목소리로)", "('지침 vs 게으름' 재구성)"];
for (const text of unmatchedCases) {
const { cues, unmatched } = parseStageDirections(text);
if (cues.length !== 0 || unmatched.length !== 1) {
fail(`지문 "${text}": 미대응이어야 하는데 cues=${cues.length}, unmatched=${unmatched.length}`);
}
}
const preCase = parseStageDirections("(한숨) 그냥요.");
if (preCase.cues[0]?.anchor !== "pre") {
fail(`앵커 "(한숨) 그냥요.": 기대 pre, 실제 ${preCase.cues[0]?.anchor}`);
}
const inlineCase = parseStageDirections("몰라요. (한숨) 다 귀찮아요.");
const inlineCue = inlineCase.cues[0];
if (inlineCue?.anchor !== "inline") {
fail(`앵커 "몰라요. (한숨) 다 귀찮아요.": 기대 inline, 실제 ${inlineCue?.anchor}`);
} else if (inlineCue.at < 0.36 || inlineCue.at > 0.39) {
fail(`앵커 "몰라요. (한숨) 다 귀찮아요.": at=${inlineCue.at.toFixed(4)}가 [0.36, 0.39] 밖`);
}
const postCase = parseStageDirections("그냥요 (시선 피함)");
if (postCase.cues[0]?.anchor !== "post") {
fail(`앵커 "그냥요 (시선 피함)": 기대 post, 실제 ${postCase.cues[0]?.anchor}`);
}
}
const modules = await loadModules();
const { CHANNEL_IDS } = modules["channels.ts"];
const { EXPRESSION_PRESETS } = modules["expressionPresets.ts"];
const { REACTION_CLIPS, REACTION_CLIP_IDS } = modules["clipCatalog.ts"];
const { DEFAULT_DEMEANOR, demeanorFor } = modules["demeanorDefaults.ts"];
const { STAGE_DIRECTION_RULES } = modules["stageDirectionLexicon.ts"];
const { parseStageDirections } = modules["performance.ts"];
const personaDemeanors = ["P1", "P2", "P3", "P4", "P5", "P6", "P7"].map((code) => [code, demeanorFor(code)]);
const { pairCount, minL1, minL1Pair } = checkDiscriminability(CHANNEL_IDS, EXPRESSION_PRESETS);
checkChannelKeys(CHANNEL_IDS, EXPRESSION_PRESETS, REACTION_CLIPS, DEFAULT_DEMEANOR, personaDemeanors);
checkClipKeyframes(REACTION_CLIPS);
checkClipReferences(REACTION_CLIP_IDS, DEFAULT_DEMEANOR, personaDemeanors, STAGE_DIRECTION_RULES);
checkStageDirectionSamples(parseStageDirections);
if (failures.length > 0) {
for (const message of failures) console.error(`FAIL: ${message}`);
console.error(`check-avatar-presets: 실패 ${failures.length}건`);
process.exit(1);
}
console.log(
`check-avatar-presets: 통과 (프리셋 쌍 ${pairCount}개 전부 구분 가능, 최소 L1 ${minL1.toFixed(2)}[${minL1Pair}], 클립 ${REACTION_CLIP_IDS.length}개, 지문 표본 검증 완료)`,
);

View file

@ -50,6 +50,7 @@ const PendingApproval = lazy(() => import("./pages/PendingApproval"));
const LearnerHome = lazy(() => import("./pages/LearnerHome"));
const AvatarExpressionLab = lazy(() => import("./pages/AvatarExpressionLab"));
const AvatarPreview = lazy(() => import("./pages/AvatarPreview"));
const AvatarLab = lazy(() => import("./pages/AvatarLab"));
const Session = lazy(() => import("./pages/Session"));
const SessionReview = lazy(() => import("./pages/SessionReview"));
const Professor = lazy(() => import("./pages/Professor"));
@ -484,6 +485,9 @@ function AppRoutes() {
{/* dev: 인증 없는 아바타 컴포지션 튜닝 페이지 (실서비스 아님) */}
<Route path="/dev/avatar-preview" element={<AvatarPreview />} />
{/* dev: 인증 없는 아바타 v3 표현 엔진 Lab (실서비스 아님) */}
<Route path="/dev/avatar-lab" element={<AvatarLab />} />
<Route
path="/onboarding"
element={

View file

@ -0,0 +1,138 @@
/* =====================================================================
아바타 v3 채널 계약 — docs/decisions/avatar-expression-engine-v3.md §4 정본.
신호 쪽은 채널을 모르고 cue만 만들고, 렌더러는 cue를 모르고 채널만 읽는다.
채널을 추가·삭제할 때는 결정문 §4 표를 먼저 고친다.
===================================================================== */
export interface ChannelSpec {
min: number;
max: number;
/** 기본 자세 이전의 중립값. */
neutral: number;
/** 표정층 스프링 시간상수(ms). 0이면 평활 없이 즉시 적용. */
tauMs: number;
/** 대응 Live2D 파라미터 id. 표준에 없으면 custom 이름. */
live2d: string;
}
const HEAD = 350;
const EYE = 120;
const GAZE = 90;
const MOUTH = 160;
const OVERLAY = 600;
export const CHANNEL_SPECS = {
headYaw: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamAngleX" },
headPitch: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamAngleY" },
headRoll: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamAngleZ" },
bodyYaw: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamBodyAngleX" },
bodyLean: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamBodyAngleY" },
bodyRoll: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamBodyAngleZ" },
shoulderRaise: { min: -1, max: 1, neutral: 0, tauMs: HEAD, live2d: "ParamShoulderY" },
breath: { min: 0, max: 1, neutral: 0, tauMs: 0, live2d: "ParamBreath" },
eyeOpenL: { min: 0, max: 1.3, neutral: 1, tauMs: EYE, live2d: "ParamEyeLOpen" },
eyeOpenR: { min: 0, max: 1.3, neutral: 1, tauMs: EYE, live2d: "ParamEyeROpen" },
eyeSmileL: { min: 0, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamEyeLSmile" },
eyeSmileR: { min: 0, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamEyeRSmile" },
lidTension: { min: 0, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamEyeTension" },
gazeX: { min: -1, max: 1, neutral: 0, tauMs: GAZE, live2d: "ParamEyeBallX" },
gazeY: { min: -1, max: 1, neutral: 0, tauMs: GAZE, live2d: "ParamEyeBallY" },
pupilSize: { min: -1, max: 1, neutral: 0, tauMs: 300, live2d: "ParamEyeBallForm" },
browLY: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLY" },
browRY: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowRY" },
browLAngle: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLAngle" },
browRAngle: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowRAngle" },
browLX: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLX" },
browRX: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowRX" },
browForm: { min: -1, max: 1, neutral: 0, tauMs: EYE, live2d: "ParamBrowLForm" },
mouthForm: { min: -1, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthForm" },
mouthOpen: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthOpenY" },
mouthPress: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthPress" },
mouthAsym: { min: -1, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthAsym" },
mouthStretch: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthStretch" },
mouthRound: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamMouthRound" },
chinRaise: { min: 0, max: 1, neutral: 0, tauMs: MOUTH, live2d: "ParamChinRaise" },
cheek: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamCheek" },
pallor: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamPallor" },
tear: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamTear" },
sweat: { min: 0, max: 1, neutral: 0, tauMs: OVERLAY, live2d: "ParamSweat" },
} as const satisfies Record<string, ChannelSpec>;
export type ChannelId = keyof typeof CHANNEL_SPECS;
export type ChannelVector = Record<ChannelId, number>;
export type ChannelDelta = Partial<Record<ChannelId, number>>;
export const CHANNEL_IDS = Object.keys(CHANNEL_SPECS) as ChannelId[];
/** 강도에 따라 켜지는 오버레이 채널과 최소 강도(결정문 §5.2). */
export const OVERLAY_MIN_INTENSITY: Partial<Record<ChannelId, number>> = {
tear: 0.7,
cheek: 0.5,
pallor: 0.7,
sweat: 0.7,
};
/* ── 저작용 대칭 키: 좌우를 같은 값으로 펼친다 ─────────────────────── */
const SYMMETRIC_KEYS = {
eyeOpen: ["eyeOpenL", "eyeOpenR"],
eyeSmile: ["eyeSmileL", "eyeSmileR"],
browY: ["browLY", "browRY"],
browAngle: ["browLAngle", "browRAngle"],
browX: ["browLX", "browRX"],
} as const satisfies Record<string, readonly [ChannelId, ChannelId]>;
export type SymmetricKey = keyof typeof SYMMETRIC_KEYS;
export type AuthoringKey = ChannelId | SymmetricKey;
export type AuthoringDelta = Partial<Record<AuthoringKey, number>>;
/** 대칭 키를 L/R 채널로 펼친다. 같은 채널을 직접 키와 대칭 키로 동시에 주면 직접 키가 이긴다. */
export function expandAuthoring(delta: AuthoringDelta): ChannelDelta {
const out: ChannelDelta = {};
for (const [key, value] of Object.entries(delta) as Array<[AuthoringKey, number]>) {
if (key in SYMMETRIC_KEYS) {
for (const id of SYMMETRIC_KEYS[key as SymmetricKey]) {
if (!(id in delta)) out[id] = value;
}
} else {
out[key as ChannelId] = value;
}
}
return out;
}
export function neutralVector(): ChannelVector {
const out = {} as ChannelVector;
for (const id of CHANNEL_IDS) out[id] = CHANNEL_SPECS[id].neutral;
return out;
}
export function clampChannel(id: ChannelId, value: number): number {
const { min, max } = CHANNEL_SPECS[id];
return value < min ? min : value > max ? max : value;
}
/* ── 좌우 반전(미러) — 한쪽 방향성이 있는 클립을 반대로 재생할 때 ──── */
const MIRROR_NEGATE: readonly ChannelId[] = ["headYaw", "headRoll", "bodyYaw", "bodyRoll", "gazeX", "mouthAsym"];
const MIRROR_SWAP: ReadonlyArray<readonly [ChannelId, ChannelId]> = [
["eyeOpenL", "eyeOpenR"],
["eyeSmileL", "eyeSmileR"],
["browLY", "browRY"],
["browLAngle", "browRAngle"],
["browLX", "browRX"],
];
export function mirrorDelta(delta: ChannelDelta): ChannelDelta {
const out: ChannelDelta = { ...delta };
for (const id of MIRROR_NEGATE) {
if (out[id] !== undefined) out[id] = -(out[id] as number);
}
for (const [l, r] of MIRROR_SWAP) {
const lv = delta[l];
const rv = delta[r];
if (lv === undefined) delete out[r];
else out[r] = lv;
if (rv === undefined) delete out[l];
else out[l] = rv;
}
return out;
}

View file

@ -0,0 +1,349 @@
/* =====================================================================
아바타 v3 반응 클립 카탈로그 — docs/decisions/avatar-expression-engine-v3.md §6 정본 값.
트랙 값은 표정층 결과에 더하는 delta다. 키프레임 [tMs, value, ease]에서 ease는
직전 키프레임에서 이 키프레임으로 가는 구간의 곡선이며 "hold"는 계단(직전 값 유지 후 도달)이다.
mirrorable 클립은 스케줄러가 시드 난수로 좌우 반전해 재생할 수 있다.
키프레임 값 변경은 오케스트레이터 설계 결정이다.
===================================================================== */
import { expandAuthoring, type AuthoringKey, type ChannelId } from "./channels";
export type ClipEase = "linear" | "in" | "out" | "inOut" | "hold";
export type Keyframe = readonly [tMs: number, value: number, ease?: ClipEase];
export type IdleSuppress = "blink" | "saccade" | "lipsync" | "sway";
export const REACTION_CLIP_IDS = [
"nod_small",
"nod_deep",
"head_shake",
"head_drop",
"look_away_side",
"look_down",
"glance_up",
"flinch",
"freeze",
"sigh",
"deep_breath",
"swallow",
"lip_press",
"lip_bite",
"scoff",
"eye_roll_small",
"shrug",
"lean_back",
"lean_forward",
"tear_up",
"blink_flurry",
"fidget_sway",
"forced_smile",
"nervous_laugh",
"jaw_clench",
"silence_hold",
] as const;
export type ReactionClipId = (typeof REACTION_CLIP_IDS)[number];
interface ClipSpec {
label: string;
durationMs: number;
fadeInMs: number;
fadeOutMs: number;
priority: 1 | 2 | 3;
cooldownMs: number;
mirrorable?: boolean;
suppress?: readonly IdleSuppress[];
tracks: Partial<Record<AuthoringKey, readonly Keyframe[]>>;
}
export interface ReactionClip extends Omit<ClipSpec, "tracks"> {
id: ReactionClipId;
tracks: Partial<Record<ChannelId, readonly Keyframe[]>>;
}
/** 시작 0 → peak 도달 → holdUntil까지 유지 → end에 0 복귀하는 사다리꼴 트랙. */
function plateau(peak: number, riseMs: number, holdUntilMs: number, endMs: number, rise: ClipEase = "out"): readonly Keyframe[] {
return [
[0, 0],
[riseMs, peak, rise],
[holdUntilMs, peak, "hold"],
[endMs, 0, "inOut"],
];
}
const CLIP_SPECS: Record<ReactionClipId, ClipSpec> = {
nod_small: {
label: "작게 끄덕",
durationMs: 900, fadeInMs: 80, fadeOutMs: 150, priority: 1, cooldownMs: 1500,
tracks: {
headPitch: [[0, 0], [180, -0.15, "out"], [360, 0, "inOut"], [540, -0.12, "out"], [760, 0, "inOut"]],
},
},
nod_deep: {
label: "깊게 끄덕",
durationMs: 1400, fadeInMs: 100, fadeOutMs: 200, priority: 2, cooldownMs: 3000,
tracks: {
headPitch: plateau(-0.35, 450, 800, 1300, "inOut"),
eyeOpen: plateau(-0.2, 450, 800, 1300, "inOut"),
},
},
head_shake: {
label: "고개 젓기",
durationMs: 1100, fadeInMs: 80, fadeOutMs: 150, priority: 2, cooldownMs: 2500,
tracks: {
headYaw: [[0, 0], [180, 0.2, "inOut"], [420, -0.2, "inOut"], [660, 0.14, "inOut"], [880, -0.06, "inOut"], [1100, 0, "inOut"]],
},
},
head_drop: {
label: "고개 떨굼",
durationMs: 3200, fadeInMs: 150, fadeOutMs: 400, priority: 2, cooldownMs: 6000,
suppress: ["saccade"],
tracks: {
headPitch: plateau(-0.45, 600, 2600, 3200),
gazeY: plateau(-0.6, 400, 2700, 3200),
shoulderRaise: plateau(-0.3, 700, 2600, 3200),
eyeOpen: plateau(-0.2, 600, 2600, 3200),
},
},
look_away_side: {
label: "옆으로 시선 회피",
durationMs: 2400, fadeInMs: 100, fadeOutMs: 300, priority: 1, cooldownMs: 4000, mirrorable: true,
suppress: ["saccade"],
tracks: {
gazeX: plateau(0.7, 200, 2000, 2400),
headYaw: plateau(0.15, 450, 2000, 2400),
},
},
look_down: {
label: "시선 내림",
durationMs: 2200, fadeInMs: 100, fadeOutMs: 300, priority: 1, cooldownMs: 3000,
suppress: ["saccade"],
tracks: {
gazeY: plateau(-0.7, 250, 1800, 2200),
headPitch: plateau(-0.1, 400, 1800, 2200),
eyeOpen: plateau(-0.25, 250, 1800, 2200),
},
},
glance_up: {
label: "힐끗 봄",
durationMs: 1100, fadeInMs: 60, fadeOutMs: 200, priority: 1, cooldownMs: 3000,
suppress: ["saccade"],
tracks: {
gazeY: plateau(0.35, 150, 650, 1100),
eyeOpen: plateau(0.1, 150, 650, 1100),
},
},
flinch: {
label: "움찔",
durationMs: 700, fadeInMs: 30, fadeOutMs: 250, priority: 3, cooldownMs: 2000,
suppress: ["blink"],
tracks: {
headPitch: [[0, 0], [90, 0.15, "out"], [700, 0, "inOut"]],
bodyLean: [[0, 0], [100, -0.25, "out"], [700, 0, "inOut"]],
shoulderRaise: [[0, 0], [90, 0.5, "out"], [700, 0, "inOut"]],
eyeOpen: [[0, 0], [60, -0.8, "out"], [160, 0.15, "out"], [700, 0, "inOut"]],
browY: [[0, 0], [100, 0.3, "out"], [700, 0, "inOut"]],
},
},
freeze: {
label: "얼어붙음",
durationMs: 1800, fadeInMs: 60, fadeOutMs: 400, priority: 3, cooldownMs: 4000,
suppress: ["blink", "saccade", "sway"],
tracks: {
eyeOpen: plateau(0.25, 120, 1500, 1800),
browY: plateau(0.35, 120, 1500, 1800),
mouthOpen: plateau(0.15, 150, 1500, 1800),
shoulderRaise: plateau(0.3, 150, 1500, 1800),
},
},
sigh: {
label: "한숨",
durationMs: 2600, fadeInMs: 100, fadeOutMs: 300, priority: 2, cooldownMs: 8000,
suppress: ["blink"],
tracks: {
shoulderRaise: [[0, 0], [800, 0.35, "inOut"], [1000, 0.35, "hold"], [2000, -0.3, "inOut"], [2600, 0, "inOut"]],
headPitch: [[0, 0], [800, 0.08, "inOut"], [2000, -0.15, "inOut"], [2600, 0, "inOut"]],
eyeOpen: [[0, 0], [900, 0, "hold"], [1500, -0.5, "inOut"], [2300, -0.1, "inOut"], [2600, 0, "inOut"]],
mouthOpen: [[0, 0], [1000, 0, "hold"], [1300, 0.2, "out"], [1900, 0.05, "inOut"], [2200, 0, "inOut"]],
bodyLean: [[0, 0], [800, 0.05, "inOut"], [2000, -0.1, "inOut"], [2600, 0, "inOut"]],
},
},
deep_breath: {
label: "심호흡",
durationMs: 3600, fadeInMs: 150, fadeOutMs: 300, priority: 1, cooldownMs: 10000,
tracks: {
shoulderRaise: plateau(0.4, 1500, 1800, 3400, "inOut"),
bodyLean: [[0, 0], [1500, 0.08, "inOut"], [3400, 0, "inOut"]],
eyeOpen: [[0, 0], [1500, -0.3, "inOut"], [3000, -0.1, "inOut"], [3600, 0, "inOut"]],
headPitch: [[0, 0], [1500, 0.1, "inOut"], [3400, 0, "inOut"]],
},
},
swallow: {
label: "침 삼킴",
durationMs: 800, fadeInMs: 50, fadeOutMs: 150, priority: 1, cooldownMs: 3000,
tracks: {
chinRaise: [[0, 0], [200, 0.4, "out"], [450, 0, "inOut"]],
mouthPress: [[0, 0], [150, 0.5, "out"], [500, 0.2, "inOut"], [800, 0, "inOut"]],
headPitch: [[0, 0], [250, -0.05, "inOut"], [800, 0, "inOut"]],
},
},
lip_press: {
label: "입술 누름",
durationMs: 1800, fadeInMs: 80, fadeOutMs: 250, priority: 1, cooldownMs: 3000,
tracks: {
mouthPress: plateau(0.7, 200, 1400, 1800),
mouthForm: plateau(-0.15, 200, 1400, 1800),
chinRaise: plateau(0.2, 200, 1400, 1800),
},
},
lip_bite: {
label: "입술 깨묾",
durationMs: 1600, fadeInMs: 80, fadeOutMs: 250, priority: 1, cooldownMs: 4000, mirrorable: true,
tracks: {
mouthPress: plateau(0.6, 180, 1200, 1600),
mouthAsym: plateau(0.35, 180, 1200, 1600),
gazeY: plateau(-0.3, 250, 1200, 1600),
},
},
scoff: {
label: "피식·코웃음",
durationMs: 1200, fadeInMs: 60, fadeOutMs: 250, priority: 2, cooldownMs: 4000, mirrorable: true,
tracks: {
mouthAsym: plateau(0.6, 150, 800, 1200),
headYaw: plateau(0.1, 200, 800, 1200),
gazeY: plateau(0.3, 200, 700, 1100),
headPitch: [[0, 0], [120, 0.08, "out"], [600, 0, "inOut"]],
mouthOpen: [[0, 0], [100, 0.08, "out"], [250, 0, "inOut"]],
},
},
eye_roll_small: {
label: "눈 굴림",
durationMs: 1100, fadeInMs: 60, fadeOutMs: 200, priority: 2, cooldownMs: 6000, mirrorable: true,
suppress: ["saccade"],
tracks: {
gazeY: [[0, 0], [250, 0.6, "out"], [550, 0.5, "inOut"], [900, 0.1, "inOut"], [1100, 0, "inOut"]],
gazeX: [[0, 0], [250, 0.1, "out"], [550, 0.5, "inOut"], [900, 0.2, "inOut"], [1100, 0, "inOut"]],
eyeOpen: [[0, 0], [250, -0.1, "out"], [1100, 0, "inOut"]],
},
},
shrug: {
label: "어깨 으쓱",
durationMs: 1300, fadeInMs: 80, fadeOutMs: 200, priority: 1, cooldownMs: 3000, mirrorable: true,
tracks: {
shoulderRaise: plateau(0.6, 300, 700, 1300),
browY: plateau(0.3, 300, 700, 1300),
headRoll: [[0, 0], [300, 0.1, "out"], [1000, 0, "inOut"]],
mouthForm: [[0, 0], [300, -0.2, "out"], [1000, 0, "inOut"]],
},
},
lean_back: {
label: "물러남",
durationMs: 3000, fadeInMs: 150, fadeOutMs: 400, priority: 1, cooldownMs: 5000,
tracks: {
bodyLean: plateau(-0.4, 500, 2400, 3000),
headPitch: plateau(0.08, 500, 2400, 3000),
shoulderRaise: plateau(0.1, 500, 2400, 3000),
},
},
lean_forward: {
label: "다가옴",
durationMs: 3000, fadeInMs: 150, fadeOutMs: 400, priority: 1, cooldownMs: 5000,
tracks: {
bodyLean: plateau(0.3, 600, 2400, 3000),
eyeOpen: plateau(0.08, 600, 2400, 3000),
browY: plateau(0.1, 600, 2400, 3000),
},
},
tear_up: {
label: "눈물 고임",
durationMs: 4000, fadeInMs: 200, fadeOutMs: 800, priority: 2, cooldownMs: 12000,
tracks: {
tear: [[0, 0], [1500, 0.4, "inOut"], [3600, 0.4, "hold"], [4000, 0.25, "inOut"]],
chinRaise: plateau(0.35, 800, 3000, 4000),
browAngle: plateau(0.35, 800, 3000, 4000),
eyeOpen: plateau(-0.15, 800, 3000, 4000),
mouthPress: plateau(0.3, 800, 3000, 4000),
},
},
blink_flurry: {
label: "잦은 깜빡임",
durationMs: 1200, fadeInMs: 0, fadeOutMs: 0, priority: 1, cooldownMs: 3000,
suppress: ["blink"],
tracks: {
eyeOpen: [
[0, 0], [60, -1, "in"], [130, 0, "out"],
[330, -1, "in"], [400, 0, "out"],
[620, -1, "in"], [690, 0, "out"],
[900, -1, "in"], [970, 0, "out"],
[1200, 0, "hold"],
],
},
},
fidget_sway: {
label: "몸 흔들림",
durationMs: 2400, fadeInMs: 150, fadeOutMs: 300, priority: 1, cooldownMs: 5000, mirrorable: true,
suppress: ["sway"],
tracks: {
bodyRoll: [[0, 0], [400, 0.12, "inOut"], [900, -0.1, "inOut"], [1400, 0.08, "inOut"], [1900, -0.05, "inOut"], [2400, 0, "inOut"]],
headRoll: [[0, 0], [450, -0.06, "inOut"], [950, 0.05, "inOut"], [1500, -0.03, "inOut"], [2400, 0, "inOut"]],
gazeX: [[0, 0], [500, 0.3, "inOut"], [1100, -0.2, "inOut"], [1700, 0.1, "inOut"], [2400, 0, "inOut"]],
},
},
forced_smile: {
label: "억지 미소",
durationMs: 2200, fadeInMs: 100, fadeOutMs: 300, priority: 2, cooldownMs: 3000,
tracks: {
mouthForm: plateau(0.4, 250, 1800, 2200),
mouthPress: plateau(0.2, 250, 1800, 2200),
browAngle: plateau(0.15, 250, 1800, 2200),
},
},
nervous_laugh: {
label: "어색한 웃음",
durationMs: 1400, fadeInMs: 60, fadeOutMs: 250, priority: 2, cooldownMs: 5000, mirrorable: true,
tracks: {
mouthForm: [[0, 0], [150, 0.45, "out"], [900, 0.3, "inOut"], [1400, 0, "inOut"]],
mouthOpen: [[0, 0], [150, 0.25, "out"], [300, 0.1, "inOut"], [450, 0.25, "out"], [600, 0.1, "inOut"], [900, 0, "inOut"]],
eyeSmile: [[0, 0], [150, 0.3, "out"], [900, 0.1, "inOut"], [1400, 0, "inOut"]],
gazeX: plateau(0.4, 200, 1100, 1400),
headPitch: [[0, 0], [150, -0.08, "out"], [300, 0, "inOut"], [450, -0.08, "out"], [600, 0, "inOut"]],
shoulderRaise: [[0, 0], [150, 0.15, "out"], [600, 0.15, "hold"], [1000, 0, "inOut"]],
browAngle: plateau(0.2, 150, 900, 1400),
},
},
jaw_clench: {
label: "이 악묾",
durationMs: 2000, fadeInMs: 80, fadeOutMs: 300, priority: 2, cooldownMs: 4000,
tracks: {
mouthPress: plateau(0.8, 200, 1600, 2000),
browX: plateau(-0.4, 200, 1600, 2000),
browY: plateau(-0.2, 200, 1600, 2000),
lidTension: plateau(0.4, 200, 1600, 2000),
headPitch: plateau(-0.05, 200, 1600, 2000),
},
},
silence_hold: {
label: "침묵 유지",
durationMs: 4000, fadeInMs: 200, fadeOutMs: 400, priority: 2, cooldownMs: 6000,
suppress: ["blink", "saccade", "sway", "lipsync"],
tracks: {
gazeY: plateau(-0.55, 500, 3500, 4000),
headPitch: plateau(-0.12, 500, 3500, 4000),
eyeOpen: [[0, 0], [500, -0.2, "out"], [1800, -0.2, "hold"], [2000, -0.9, "inOut"], [2250, -0.2, "inOut"], [3500, -0.2, "hold"], [4000, 0, "inOut"]],
},
},
};
function expandTracks(tracks: ClipSpec["tracks"]): ReactionClip["tracks"] {
const out: Partial<Record<ChannelId, readonly Keyframe[]>> = {};
for (const [key, frames] of Object.entries(tracks) as Array<[AuthoringKey, readonly Keyframe[]]>) {
for (const id of Object.keys(expandAuthoring({ [key]: 1 })) as ChannelId[]) out[id] = frames;
}
return out;
}
export const REACTION_CLIPS: Record<ReactionClipId, ReactionClip> = Object.fromEntries(
REACTION_CLIP_IDS.map((id) => {
const { tracks, ...rest } = CLIP_SPECS[id];
return [id, { id, ...rest, tracks: expandTracks(tracks) }];
}),
) as Record<ReactionClipId, ReactionClip>;

View file

@ -0,0 +1,78 @@
/* =====================================================================
아바타 v3 반응 클립 재생기 — 결정문 §6.1 키프레임 보간·엔벨로프·합성.
순수 함수뿐이다. 시간은 인자로만 받는다.
===================================================================== */
import { mirrorDelta, type ChannelDelta, type ChannelId } from "./channels";
import type { ClipEase, Keyframe, ReactionClip } from "./clipCatalog";
function ease(kind: ClipEase, t: number): number {
switch (kind) {
case "in":
return t * t;
case "out":
return 1 - (1 - t) * (1 - t);
case "inOut":
return 3 * t * t - 2 * t * t * t;
case "hold":
return t >= 1 ? 1 : 0;
case "linear":
default:
return t;
}
}
function sampleTrack(frames: readonly Keyframe[], tMs: number): number {
const first = frames[0];
if (tMs <= first[0]) return first[1];
const last = frames[frames.length - 1];
if (tMs >= last[0]) return last[1];
for (let i = 1; i < frames.length; i++) {
const [t1, v1, arrivalEase] = frames[i];
if (tMs > t1) continue;
const [t0, v0] = frames[i - 1];
const span = t1 - t0;
const localT = span <= 0 ? 1 : (tMs - t0) / span;
const eased = ease(arrivalEase ?? "linear", localT);
return v0 + (v1 - v0) * eased;
}
return last[1];
}
/** 클립의 tMs 시점 채널 delta(가산값). t가 구간 밖이면 빈 delta. */
export function sampleClip(clip: ReactionClip, tMs: number): ChannelDelta {
if (tMs < 0 || tMs > clip.durationMs) return {};
const out: ChannelDelta = {};
for (const id of Object.keys(clip.tracks) as ChannelId[]) {
const frames = clip.tracks[id];
if (!frames || frames.length === 0) continue;
out[id] = sampleTrack(frames, tMs);
}
return out;
}
/** fadeIn→hold→fadeOut 엔벨로프(0..1). 구간 밖은 0. */
export function envelope(clip: ReactionClip, tMs: number): number {
if (tMs < 0 || tMs > clip.durationMs) return 0;
const { fadeInMs, fadeOutMs, durationMs } = clip;
if (fadeInMs > 0 && tMs < fadeInMs) return tMs / fadeInMs;
const fadeOutStart = durationMs - fadeOutMs;
if (fadeOutMs > 0 && tMs > fadeOutStart) return (durationMs - tMs) / fadeOutMs;
return 1;
}
export interface ResolveClipOptions {
weight?: number;
mirror?: boolean;
}
/** sampleClip × envelope × weight, mirror면 좌우 반전까지 적용한 최종 delta. */
export function resolveClipDelta(clip: ReactionClip, tMs: number, opts: ResolveClipOptions = {}): ChannelDelta {
const raw = sampleClip(clip, tMs);
const scale = envelope(clip, tMs) * (opts.weight ?? 1);
const out: ChannelDelta = {};
for (const id of Object.keys(raw) as ChannelId[]) {
out[id] = (raw[id] as number) * scale;
}
return opts.mirror ? mirrorDelta(out) : out;
}

View file

@ -0,0 +1,452 @@
/* =====================================================================
아바타 v3 발화 동반층 — 결정문 §5.5 정본. 말할 때 머리·몸·눈썹·시선·깜빡임을
만드는 순수 모듈이다. 발화 타임라인(§5.4, lipsync.ts)의 구·모음 목표·강세·휴지를
입력으로 시드 난수 1회로 계획(CoSpeechPlan)을 만들고, 매 프레임 그 계획을
표본해 채널 delta와 깜빡임 요청을 낸다. 렌더러·DOM과 무관하다.
스프링(진입·이탈 시 매끄럽게 잇기)은 여기 두지 않는다 — engine.ts가 이 결과를
목표값으로 스프링한다(발화층 lipsync 패턴과 동일, §5.4 "표본·평활" 주석 참고).
===================================================================== */
import type { ChannelDelta } from "./channels";
import { createRng } from "./rng";
import { sampleEnvelope, type SpeechEnvelope } from "./speechEnvelope";
import type { SpeechPauseWindow, SpeechPhraseInfo, SpeechTimeline } from "./lipsync";
/** demeanor.speech에서 발화 동반층이 쓰는 부분만 뽑은 것(순환 참조 방지 — demeanorDefaults.ts를 import하지 않는다). */
export interface CoSpeechStyle {
/** 모든 크기(깜빡임 제외)에 곱하는 배율(§5.5). */
gesture: number;
/** 구를 시작할 때 시선을 피할 확률. */
gazeAvertProb: number;
}
interface Breakpoint {
t: number;
v: number;
}
interface StressPulseSpec {
peakMs: number;
riseMs: number;
fallMs: number;
headPitch: number;
bodyLean: number;
shoulderRaise: number;
}
interface PhraseEndCosineSpec {
kind: "cosine";
peakMs: number;
riseMs: number;
fallMs: number;
headPitch: number;
headRoll: number;
}
interface PhraseEndTrapezoidSpec {
kind: "trapezoid";
breakpoints: Breakpoint[];
headPitch: number;
headRoll: number;
}
type PhraseEndSpec = PhraseEndCosineSpec | PhraseEndTrapezoidSpec;
interface BreathSpec {
breakpoints: Breakpoint[];
shoulderRaise: number;
bodyLean: number;
}
interface BrowSpec {
breakpoints: Breakpoint[];
browLY: number;
browRY: number;
}
interface GazeSpec {
breakpoints: Breakpoint[];
gazeX: number;
gazeY: number;
}
export interface CoSpeechPlan {
totalDurationMs: number;
/** 구 표류(§5.5) — 발화 전체를 잇는 연속 곡선. 스프레드 순서: [진입 전 0, 각 구 진입·유지, 마지막 구 뒤 700ms 복귀]. */
driftYaw: Breakpoint[];
driftRoll: Breakpoint[];
driftPitch: Breakpoint[];
/** 강세 박(§5.5) — 올림 코사인 펄스 목록. */
stressPulses: StressPulseSpec[];
/** 구 끝(§5.5) — 평서는 코사인 끄덕임, 질문은 사다리꼴 들림. 이어짐은 항목 없음. */
phraseEndPulses: PhraseEndSpec[];
/** 들숨(§5.5). */
breathPulses: BreathSpec[];
/** 눈썹 박(§5.5). */
browPulses: BrowSpec[];
/** 시선 회피(§5.5). */
gazePulses: GazeSpec[];
/** 깜빡임 요청 시각(발화 시작 기준 ms, 확률 필터링 완료, 오름차순). */
blinkRequests: number[];
}
export interface CoSpeechSample {
delta: ChannelDelta;
blinkNow?: boolean;
}
function clamp01(v: number): number {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function smoothstep(u: number): number {
const c = clamp01(u);
return c * c * (3 - 2 * c);
}
/** 올림 코사인 b(t)(§5.5): 정점 전 riseMs 동안 0→1, 정점 뒤 fallMs 동안 1→0. */
function raisedCosine(t: number, peakMs: number, riseMs: number, fallMs: number): number {
if (t < peakMs) {
if (riseMs <= 0) return 0;
const u = (t - (peakMs - riseMs)) / riseMs;
if (u <= 0) return 0;
if (u >= 1) return 1;
return 0.5 * (1 - Math.cos(Math.PI * u));
}
if (fallMs <= 0) return t === peakMs ? 1 : 0;
const u = (t - peakMs) / fallMs;
if (u >= 1) return 0;
return 0.5 * (1 + Math.cos(Math.PI * u));
}
/** breakpoints를 smoothstep으로 잇는 구간별 표본(구 표류·사다리꼴 펄스 공용). 구간 밖은 양끝 값으로 고정된다. */
function sampleBreakpoints(bps: readonly Breakpoint[], t: number): number {
if (bps.length === 0) return 0;
if (t <= bps[0].t) return bps[0].v;
const lastBp = bps[bps.length - 1];
if (t >= lastBp.t) return lastBp.v;
let i = 0;
while (i < bps.length - 1 && bps[i + 1].t <= t) i++;
const a = bps[i];
const b = bps[i + 1];
const span = b.t - a.t;
const u = span > 0 ? (t - a.t) / span : 1;
return a.v + (b.v - a.v) * smoothstep(u);
}
/** 뒤 breakpoint의 t가 앞보다 앞서지 않게 고정한다(§5.5 "구마다 새로 뽑아" 곡선을 이어붙일 때,
아주 짧은 구가 연달아 나와 램프 구간이 겹치는 드문 경우의 근사 — 작업 보고 참고). */
function pushBp(arr: Breakpoint[], t: number, v: number): void {
const last = arr[arr.length - 1];
arr.push({ t: last ? Math.max(t, last.t) : t, v });
}
interface PerPhraseDraw {
yawSign: number;
yawMag: number;
rollSign: number;
rollMag: number;
pitchVal: number;
browOn: boolean;
gazeOn: boolean;
gazeHoldMs: number;
}
const DRIFT_RAMP_LEAD_MS = 100;
const DRIFT_RAMP_MS = 450;
const DRIFT_RELEASE_MS = 700;
function buildDrift(
phrases: readonly SpeechPhraseInfo[],
draws: readonly PerPhraseDraw[],
g: number,
): { yaw: Breakpoint[]; roll: Breakpoint[]; pitch: Breakpoint[] } {
const yaw: Breakpoint[] = [];
const roll: Breakpoint[] = [];
const pitch: Breakpoint[] = [];
let lastYaw = 0;
let lastRoll = 0;
let lastPitch = 0;
let lastEndMs: number | null = null;
phrases.forEach((ph, i) => {
if (ph.syllableTargetsMs.length === 0) return;
const draw = draws[i];
const rampStart = ph.startMs - DRIFT_RAMP_LEAD_MS;
const rampEnd = rampStart + DRIFT_RAMP_MS;
const targetYaw = draw.yawSign * draw.yawMag * g;
const targetRoll = draw.rollSign * draw.rollMag * g;
const targetPitch = draw.pitchVal * g;
pushBp(yaw, rampStart, lastYaw);
pushBp(yaw, rampEnd, targetYaw);
pushBp(roll, rampStart, lastRoll);
pushBp(roll, rampEnd, targetRoll);
pushBp(pitch, rampStart, lastPitch);
pushBp(pitch, rampEnd, targetPitch);
lastYaw = targetYaw;
lastRoll = targetRoll;
lastPitch = targetPitch;
lastEndMs = ph.endMs;
});
if (lastEndMs !== null) {
pushBp(yaw, lastEndMs, lastYaw);
pushBp(yaw, lastEndMs + DRIFT_RELEASE_MS, 0);
pushBp(roll, lastEndMs, lastRoll);
pushBp(roll, lastEndMs + DRIFT_RELEASE_MS, 0);
pushBp(pitch, lastEndMs, lastPitch);
pushBp(pitch, lastEndMs + DRIFT_RELEASE_MS, 0);
}
return { yaw, roll, pitch };
}
function buildStressPulses(phrases: readonly SpeechPhraseInfo[], g: number): StressPulseSpec[] {
const out: StressPulseSpec[] = [];
for (const ph of phrases) {
const mul = (ph.kind === "exclaim" ? 1.4 : 1) * g;
for (const idx of ph.stressedIndices) {
const vowelMs = ph.syllableTargetsMs[idx];
if (vowelMs === undefined) continue;
out.push({
peakMs: vowelMs - 80,
riseMs: 120,
fallMs: 260,
headPitch: -0.25 * mul,
bodyLean: 0.12 * mul,
shoulderRaise: 0.08 * mul,
});
}
}
return out;
}
function buildPhraseEndPulses(phrases: readonly SpeechPhraseInfo[], draws: readonly PerPhraseDraw[], g: number): PhraseEndSpec[] {
const out: PhraseEndSpec[] = [];
phrases.forEach((ph, i) => {
const n = ph.syllableTargetsMs.length;
if (n === 0) return;
const lastMs = ph.syllableTargetsMs[n - 1];
if (ph.kind === "statement") {
out.push({ kind: "cosine", peakMs: lastMs, riseMs: 150, fallMs: 400, headPitch: -0.3 * g, headRoll: 0 });
return;
}
if (ph.kind === "question") {
/* "마지막 두 음절에 걸쳐" — 1음절 구는 음절 하나 분량(150ms)을 앞당겨 근사한다. */
const riseStart = n >= 2 ? ph.syllableTargetsMs[n - 2] : lastMs - 150;
const holdEnd = ph.endMs + 300;
const releaseEnd = holdEnd + 400;
out.push({
kind: "trapezoid",
breakpoints: [
{ t: riseStart, v: 0 },
{ t: lastMs, v: 1 },
{ t: holdEnd, v: 1 },
{ t: releaseEnd, v: 0 },
],
headPitch: 0.12 * g,
headRoll: 0.15 * g * draws[i].rollSign,
});
}
/* 이어짐(",")은 구 끝 동작이 없다(§5.5). */
});
return out;
}
const BREATH_LEAD_MS = 250;
const BREATH_PAUSE_MIN_MS = 200;
const BREATH_EXHALE_FRACTION = 0.4;
function buildBreathPulses(phrases: readonly SpeechPhraseInfo[], g: number): BreathSpec[] {
const out: BreathSpec[] = [];
phrases.forEach((ph, i) => {
if (ph.syllableTargetsMs.length === 0) return;
const prev = phrases[i - 1];
const gapMs = i === 0 ? Infinity : ph.startMs - (prev?.endMs ?? ph.startMs);
if (i !== 0 && gapMs < BREATH_PAUSE_MIN_MS) return;
const dur = Math.max(1, ph.endMs - ph.startMs);
out.push({
breakpoints: [
{ t: ph.startMs - BREATH_LEAD_MS, v: 0 },
{ t: ph.startMs, v: 1 },
{ t: ph.startMs + dur * BREATH_EXHALE_FRACTION, v: 0 },
],
shoulderRaise: 0.25 * g,
bodyLean: -0.05 * g,
});
});
return out;
}
function buildBrowPulses(phrases: readonly SpeechPhraseInfo[], draws: readonly PerPhraseDraw[], g: number): BrowSpec[] {
const out: BrowSpec[] = [];
phrases.forEach((ph, i) => {
if (!draws[i].browOn) return;
const firstStressIdx = ph.stressedIndices[0];
const targetMs = firstStressIdx === undefined ? undefined : ph.syllableTargetsMs[firstStressIdx];
if (targetMs === undefined) return;
out.push({
breakpoints: [
{ t: targetMs - 100, v: 0 },
{ t: targetMs, v: 1 },
{ t: targetMs + 150, v: 1 },
{ t: targetMs + 400, v: 0 },
],
browLY: 0.15 * g,
browRY: 0.15 * g,
});
});
return out;
}
const GAZE_AVERT_TAIL_MS = 400;
function buildGazePulses(
phrases: readonly SpeechPhraseInfo[],
draws: readonly PerPhraseDraw[],
g: number,
totalDurationMs: number,
): GazeSpec[] {
const out: GazeSpec[] = [];
phrases.forEach((ph, i) => {
const draw = draws[i];
if (!draw.gazeOn) return;
if (ph.syllableTargetsMs.length === 0) return;
if (ph.startMs >= totalDurationMs - GAZE_AVERT_TAIL_MS) return;
out.push({
breakpoints: [
{ t: ph.startMs, v: 0 },
{ t: ph.startMs + 150, v: 1 },
{ t: ph.startMs + 150 + draw.gazeHoldMs, v: 1 },
{ t: ph.startMs + 150 + draw.gazeHoldMs + 250, v: 0 },
],
gazeX: 0.25 * draw.yawSign * g,
gazeY: -0.15 * g,
});
});
return out;
}
const BLINK_PAUSE_MIN_MS = 250;
const BLINK_REQUEST_OFFSET_MS = 50;
const BLINK_PROB = 0.6;
function buildBlinkRequests(pauses: readonly SpeechPauseWindow[], rng: ReturnType<typeof createRng>): number[] {
const out: number[] = [];
for (const pause of pauses) {
if (pause.endMs - pause.startMs < BLINK_PAUSE_MIN_MS) continue;
if (rng.next() < BLINK_PROB) out.push(pause.startMs + BLINK_REQUEST_OFFSET_MS);
}
return out;
}
/**
* 발화 타임라인(§5.4)과 시드로 발화 동반 계획을 한 번 만든다(§5.5).
* rng 소비 순서(같은 시드 재현성의 근거): 구마다 순서대로
* [yaw 크기, roll 크기, pitch 값, (첫 구만) yaw 부호, roll 부호, 눈썹 박 확률, 시선 회피 확률,
* (시선 회피 발동 시) 유지 시간] → 이후 휴지 구간마다 순서대로 [깜빡임 확률].
*/
export function buildCoSpeechPlan(timeline: SpeechTimeline, style: CoSpeechStyle, seed: number): CoSpeechPlan {
const rng = createRng(seed);
const g = style.gesture;
const phrases = timeline.phrases;
let prevYawSign = 1;
const draws: PerPhraseDraw[] = phrases.map((ph, i) => {
const yawMag = rng.range(0.2, 0.4);
const rollMag = rng.range(0.1, 0.2);
const pitchVal = rng.range(-0.1, 0.05);
const yawSign = i === 0 ? (rng.next() < 0.5 ? -1 : 1) : -prevYawSign;
prevYawSign = yawSign;
const rollSign = rng.next() < 0.5 ? -1 : 1;
const browOn = ph.kind === "question" ? true : rng.next() < 0.5;
const gazeOn = rng.next() < style.gazeAvertProb;
const gazeHoldMs = gazeOn ? rng.range(500, 900) : 0;
return { yawSign, yawMag, rollSign, rollMag, pitchVal, browOn, gazeOn, gazeHoldMs };
});
const drift = buildDrift(phrases, draws, g);
return {
totalDurationMs: timeline.totalDurationMs,
driftYaw: drift.yaw,
driftRoll: drift.roll,
driftPitch: drift.pitch,
stressPulses: buildStressPulses(phrases, g),
phraseEndPulses: buildPhraseEndPulses(phrases, draws, g),
breathPulses: buildBreathPulses(phrases, g),
browPulses: buildBrowPulses(phrases, draws, g),
gazePulses: buildGazePulses(phrases, draws, g, timeline.totalDurationMs),
blinkRequests: buildBlinkRequests(timeline.pauses, rng),
};
}
/**
* 계획을 tMs(발화 시작 기준 ms) 시점으로 표본한다. prevTMs는 직전 프레임의 같은 기준
* 시각이며, (prevTMs, tMs] 구간을 지난 깜빡임 요청이 있으면 blinkNow를 세운다(§5.5).
* 음량 들썩임(§5.5)은 envelope가 있을 때만 shoulderRaise에 더한다.
*/
export function sampleCoSpeech(plan: CoSpeechPlan, tMs: number, prevTMs: number, envelope?: SpeechEnvelope): CoSpeechSample {
let headPitch = sampleBreakpoints(plan.driftPitch, tMs);
let headRoll = sampleBreakpoints(plan.driftRoll, tMs);
const headYaw = sampleBreakpoints(plan.driftYaw, tMs);
let bodyLean = 0;
let shoulderRaise = 0;
let browLY = 0;
let browRY = 0;
let gazeX = 0;
let gazeY = 0;
for (const p of plan.stressPulses) {
const b = raisedCosine(tMs, p.peakMs, p.riseMs, p.fallMs);
if (b === 0) continue;
headPitch += p.headPitch * b;
bodyLean += p.bodyLean * b;
shoulderRaise += p.shoulderRaise * b;
}
for (const p of plan.phraseEndPulses) {
const b = p.kind === "cosine" ? raisedCosine(tMs, p.peakMs, p.riseMs, p.fallMs) : sampleBreakpoints(p.breakpoints, tMs);
if (b === 0) continue;
headPitch += p.headPitch * b;
headRoll += p.headRoll * b;
}
for (const p of plan.breathPulses) {
const b = sampleBreakpoints(p.breakpoints, tMs);
if (b === 0) continue;
shoulderRaise += p.shoulderRaise * b;
bodyLean += p.bodyLean * b;
}
if (envelope) {
const p90 = envelope.p90 || 1e-6;
const env = sampleEnvelope(envelope, tMs);
shoulderRaise += 0.1 * Math.max(0, env / p90 - 0.5);
}
for (const p of plan.browPulses) {
const b = sampleBreakpoints(p.breakpoints, tMs);
if (b === 0) continue;
browLY += p.browLY * b;
browRY += p.browRY * b;
}
for (const p of plan.gazePulses) {
const b = sampleBreakpoints(p.breakpoints, tMs);
if (b === 0) continue;
gazeX += p.gazeX * b;
gazeY += p.gazeY * b;
}
const delta: ChannelDelta = { headYaw, headPitch, headRoll, bodyLean, shoulderRaise, browLY, browRY, gazeX, gazeY };
const blinkNow = plan.blinkRequests.some((e) => e > prevTMs && e <= tMs);
return blinkNow ? { delta, blinkNow: true } : { delta };
}
/** 표시용: tMs 시점에 강세 박이 활성인지(rise~fall 구간 안인지). Lab 채널 표 옆 표시에 쓴다. */
export function isStressPulseActive(plan: CoSpeechPlan, tMs: number): boolean {
return plan.stressPulses.some((p) => tMs >= p.peakMs - p.riseMs && tMs <= p.peakMs + p.fallMs);
}

View file

@ -0,0 +1,198 @@
/* =====================================================================
아바타 v3 페르소나 성향(demeanor) 기본값 — 결정문 §7.2 ⑤ / §8.3.
기본 자세·표현 증폭·깜빡임·시선·idle 클립 빈도를 캐릭터 카드 설정
(data/personas/P*.json, apps/api/app/services/persona.py SEED_PERSONAS)에서 읽어 정했다.
4단계에서 캐릭터 카드 avatar.demeanor 블록으로 옮긴다. 값 변경은 설계 결정이다.
===================================================================== */
import type { AvatarState } from "../persona";
import { expandAuthoring, type AuthoringDelta, type ChannelDelta } from "./channels";
import type { ReactionClipId } from "./clipCatalog";
export interface IdleClipRule {
clip: ReactionClipId;
/** 다음 발동까지 간격 범위(ms). 시드 난수로 고른다. */
everyMs: readonly [number, number];
/** 이 상태에서만 발동. 생략하면 idle·listening 모두. speaking·thinking에는 idle 클립을 쓰지 않는다. */
when?: Extract<AvatarState, "idle" | "listening">;
/** 개방도가 이 값 미만일 때만 발동. 생략하면 제한 없음. */
maxOpenness?: number;
}
export interface Demeanor {
basePose: ChannelDelta;
/** 표정 delta 증폭. 억누르는 캐릭터는 1보다 작다. */
expressivity: number;
blinkIntervalMs: readonly [number, number];
blinkDurationMs: number;
saccadeIntervalMs: readonly [number, number];
/** 시선 미세 이동 크기(gaze 채널 단위). */
saccadeAmplitude: number;
/** 호흡 외 미세 몸 흔들림 크기(0..1 배율). */
swayAmplitude: number;
idleClips: readonly IdleClipRule[];
speech: SpeechStyle;
}
/** 발화층(립싱크) 성향 — 결정문 §5.4. */
export interface SpeechStyle {
/** 조음 폭: 발화 입 모양(open·wide·round)에 곱한다. 웅얼거리는 캐릭터는 작다. */
articulation: number;
/** 오디오가 없을 때 텍스트만으로 타이밍을 만들 때의 발화 속도(음절/초). 한국어 대화체 평균 약 5.9. */
syllablesPerSec: number;
/** 발화 동반 움직임(고개 표류·강세 박·들숨·눈썹 박, 결정문 §5.5) 크기 배율. 위축된 캐릭터는 작다. */
gesture: number;
/** 구를 시작할 때 시선을 피할 확률(생각하며 말하기). */
gazeAvertProb: number;
}
function demeanor(spec: Omit<Demeanor, "basePose"> & { basePose: AuthoringDelta }): Demeanor {
return { ...spec, basePose: expandAuthoring(spec.basePose) };
}
/* P1 서연 — 고2, 우울·자살사고, 비자발 내방. N 0.85·E 0.25, "감정 억누르고 무덤덤한 척", 불면. */
const P1 = demeanor({
basePose: { headPitch: -0.12, shoulderRaise: -0.2, eyeOpen: -0.12, gazeY: -0.15, bodyLean: -0.05 },
/* 절제는 강도 곡선(낮은 강도 억제)과 기본 자세가 맡는다. 0.75는 강도 1에서도 표정이 안 읽혀 0.9로 올렸다(2단계-B 소유자 관찰). */
expressivity: 0.9,
blinkIntervalMs: [2600, 5200],
blinkDurationMs: 150,
saccadeIntervalMs: [3000, 7000],
saccadeAmplitude: 0.12,
swayAmplitude: 0.3,
idleClips: [
{ clip: "sigh", everyMs: [25000, 45000], maxOpenness: 0.5 },
{ clip: "look_down", everyMs: [9000, 16000], when: "listening", maxOpenness: 0.6 },
{ clip: "look_away_side", everyMs: [12000, 20000], when: "listening", maxOpenness: 0.4 },
{ clip: "lip_press", everyMs: [15000, 30000] },
],
/* 작게 웅얼거리고 느리다(무덤덤한 척·에너지 낮음). */
speech: { articulation: 0.6, syllablesPerSec: 5.0, gesture: 0.55, gazeAvertProb: 0.7 },
});
/* P2 민재 — 32세 직장인, 범불안·신체화, 논리적·장황. */
const P2 = demeanor({
basePose: { shoulderRaise: 0.2, lidTension: 0.15, browAngle: 0.1 },
expressivity: 0.85,
blinkIntervalMs: [1800, 3600],
blinkDurationMs: 110,
saccadeIntervalMs: [1800, 4200],
saccadeAmplitude: 0.18,
swayAmplitude: 0.4,
idleClips: [
{ clip: "swallow", everyMs: [10000, 18000] },
{ clip: "blink_flurry", everyMs: [14000, 26000], maxOpenness: 0.6 },
{ clip: "deep_breath", everyMs: [30000, 50000] },
],
/* 장황하고 빠르다. */
speech: { articulation: 0.85, syllablesPerSec: 6.4, gesture: 0.9, gazeAvertProb: 0.5 },
});
/* P3 지우 — 28세 미혼모, 역할 부담, 따뜻하지만 지친 톤. */
const P3 = demeanor({
basePose: { eyeOpen: -0.15, eyeSmile: 0.08, shoulderRaise: -0.15 },
expressivity: 0.9,
blinkIntervalMs: [2200, 4600],
blinkDurationMs: 140,
saccadeIntervalMs: [2600, 5600],
saccadeAmplitude: 0.14,
swayAmplitude: 0.35,
idleClips: [
{ clip: "deep_breath", everyMs: [25000, 40000] },
{ clip: "sigh", everyMs: [30000, 50000], maxOpenness: 0.6 },
{ clip: "look_down", everyMs: [12000, 20000], when: "listening" },
],
/* 따뜻하지만 지친 톤. */
speech: { articulation: 0.8, syllablesPerSec: 5.6, gesture: 0.7, gazeAvertProb: 0.45 },
});
/* P4 하늘 — 고2, 시험불안·완벽주의, 또박또박·자기검열. */
const P4 = demeanor({
basePose: { shoulderRaise: 0.15, lidTension: 0.15, eyeOpen: 0.05 },
expressivity: 1.0,
blinkIntervalMs: [1400, 3000],
blinkDurationMs: 100,
saccadeIntervalMs: [1500, 3500],
saccadeAmplitude: 0.2,
swayAmplitude: 0.5,
idleClips: [
{ clip: "blink_flurry", everyMs: [9000, 16000], maxOpenness: 0.6 },
{ clip: "fidget_sway", everyMs: [12000, 22000] },
{ clip: "swallow", everyMs: [14000, 24000] },
{ clip: "lip_bite", everyMs: [18000, 30000], maxOpenness: 0.5 },
],
/* 또박또박. */
speech: { articulation: 0.95, syllablesPerSec: 6.2, gesture: 0.65, gazeAvertProb: 0.55 },
});
/* P5 도윤 — 중3, 또래관계 소외, 무던한 척·반존대. */
const P5 = demeanor({
basePose: { bodyLean: -0.1, gazeX: 0.12, headPitch: -0.05 },
expressivity: 0.7,
blinkIntervalMs: [2400, 4800],
blinkDurationMs: 120,
saccadeIntervalMs: [2500, 5500],
saccadeAmplitude: 0.15,
swayAmplitude: 0.35,
idleClips: [
{ clip: "look_away_side", everyMs: [8000, 14000], when: "listening", maxOpenness: 0.6 },
{ clip: "shrug", everyMs: [20000, 35000] },
{ clip: "look_down", everyMs: [15000, 25000] },
],
/* 무던한 척, 입을 크게 안 벌린다. */
speech: { articulation: 0.65, syllablesPerSec: 5.8, gesture: 0.6, gazeAvertProb: 0.6 },
});
/* P6 하린 — 고3, 진로갈등, 똑부러지지만 지친 톤. */
const P6 = demeanor({
basePose: { eyeOpen: -0.08 },
expressivity: 0.9,
blinkIntervalMs: [2000, 4200],
blinkDurationMs: 120,
saccadeIntervalMs: [2200, 5000],
saccadeAmplitude: 0.14,
swayAmplitude: 0.3,
idleClips: [
{ clip: "lip_press", everyMs: [12000, 22000] },
{ clip: "deep_breath", everyMs: [25000, 40000] },
{ clip: "look_away_side", everyMs: [16000, 28000], maxOpenness: 0.5 },
],
speech: { articulation: 0.85, syllablesPerSec: 6.0, gesture: 0.75, gazeAvertProb: 0.45 },
});
/* P7 도현 — 고3, 입시 번아웃, 무덤덤·자조. */
const P7 = demeanor({
basePose: { eyeOpen: -0.25, headPitch: -0.18, shoulderRaise: -0.3, headRoll: 0.05 },
expressivity: 0.65,
blinkIntervalMs: [3000, 6000],
blinkDurationMs: 190,
saccadeIntervalMs: [3500, 8000],
saccadeAmplitude: 0.1,
swayAmplitude: 0.2,
idleClips: [
{ clip: "sigh", everyMs: [20000, 35000], maxOpenness: 0.6 },
{ clip: "look_down", everyMs: [10000, 18000] },
{ clip: "scoff", everyMs: [30000, 50000], maxOpenness: 0.4 },
],
/* 무덤덤·자조, 가장 작게 웅얼거린다. */
speech: { articulation: 0.55, syllablesPerSec: 5.2, gesture: 0.4, gazeAvertProb: 0.65 },
});
/* 성향 정의가 없는 페르소나(스튜디오 신규 등)의 기본값. */
export const DEFAULT_DEMEANOR = demeanor({
basePose: {},
expressivity: 0.9,
blinkIntervalMs: [2200, 4800],
blinkDurationMs: 120,
saccadeIntervalMs: [2500, 6000],
saccadeAmplitude: 0.14,
swayAmplitude: 0.35,
idleClips: [{ clip: "look_down", everyMs: [15000, 25000], when: "listening" }],
speech: { articulation: 0.8, syllablesPerSec: 5.9, gesture: 0.75, gazeAvertProb: 0.5 },
});
const DEMEANORS: Record<string, Demeanor> = { P1, P2, P3, P4, P5, P6, P7 };
export function demeanorFor(personaCode: string | null | undefined): Demeanor {
return (personaCode && DEMEANORS[personaCode.toUpperCase()]) || DEFAULT_DEMEANOR;
}

View file

@ -0,0 +1,596 @@
/* =====================================================================
아바타 v3 표현 엔진 — 결정문 §3 구조·§5 표정층·§6 반응층·§7 공연 스크립트.
렌더러와 무관한 순수 상태머신이다. 시계(nowMs)와 난수(rng)는 모두 주입되며
내부에서 Date/performance.now/Math.random을 직접 호출하지 않는다.
렌더러는 evaluate(nowMs)가 돌려주는 채널 벡터만 읽는다.
===================================================================== */
import {
CHANNEL_IDS,
CHANNEL_SPECS,
OVERLAY_MIN_INTENSITY,
clampChannel,
neutralVector,
type ChannelDelta,
type ChannelId,
type ChannelVector,
} from "./channels";
import { EXPRESSION_PRESETS, intensityGain } from "./expressionPresets";
import { REACTION_CLIPS, type IdleSuppress, type ReactionClipId } from "./clipCatalog";
import { resolveClipDelta } from "./clipPlayer";
import type { Demeanor } from "./demeanorDefaults";
import { createRng, type Rng } from "./rng";
import { SILENT_SHAPE, type SpeechShape } from "./lipsync";
import type { AvatarExpression, AvatarState } from "../persona";
import type { Performance } from "./performance";
export interface AvatarEngineOptions {
demeanor: Demeanor;
seed: number;
reducedMotion?: boolean;
}
export interface TriggerClipOptions {
weight?: number;
mirror?: boolean;
}
export interface PerformanceTimeline {
speechStartMs: number;
speechDurationMs: number;
}
export interface DebugActiveClip {
id: ReactionClipId;
startedMs: number;
weight: number;
mirror: boolean;
}
export interface DebugScheduledCue {
clip: ReactionClipId;
atMs: number;
source: string;
}
export interface DebugSnapshot {
activeClips: DebugActiveClip[];
scheduled: DebugScheduledCue[];
surface: { expression: AvatarExpression; intensity: number };
leakActive: boolean;
}
export interface AvatarEngine {
setState(state: AvatarState, nowMs: number): void;
setOpenness(openness: number): void;
setSurface(expression: AvatarExpression, intensity: number): void;
/** 발화층(§5.4) 목표 입 모양. null이면 목표가 X(닫힘)로 스프링 수렴한다. */
setSpeechShape(shape: SpeechShape | null): void;
/** 발화 동반층(§5.5) 목표 채널 delta(coSpeech.ts sampleCoSpeech 결과). speaking 중이면
그대로 통과시킨다(강세 박 타이밍을 지키기 위해 스프링을 걸지 않는다). speaking이 아니거나
null이면 그 값에서 τ150ms 스프링으로 0으로 되돌아간다. reduced motion이면 적용하지 않는다. */
setSpeechMotion(delta: ChannelDelta | null): void;
/** 발화 동반 깜빡임 요청(§5.5) — idle 깜빡임과 같은 경로로 지금 재생하고 idle 타이머를 다시 잡는다. */
requestSpeechBlink(nowMs: number): void;
triggerClip(id: ReactionClipId, nowMs: number, opts?: TriggerClipOptions): boolean;
playPerformance(perf: Performance, timeline: PerformanceTimeline, nowMs: number): void;
evaluate(nowMs: number): ChannelVector;
debugSnapshot(): DebugSnapshot;
}
interface ActiveClipEntry {
clipId: ReactionClipId;
startedMs: number;
weight: number;
mirror: boolean;
priority: 1 | 2 | 3;
evictAt?: number;
}
interface ScheduledCue {
clip: ReactionClipId;
atMs: number;
weight: number;
source: string;
}
interface LeakState {
expression: AvatarExpression;
intensity: number;
startAbsMs: number;
durationMs: number;
}
interface IdleClipState {
nextAtMs: number;
}
interface BlinkState {
nextAt: number;
startedAt: number | null;
}
interface SaccadeState {
nextAt: number;
targetX: number;
targetY: number;
curX: number;
curY: number;
}
const EVICT_FADE_MS = 150;
/* ── 발화층 스프링(결정문 §5.4 "임계 감쇠 스프링 τ 45ms") ──────────────────
표정층 입 스프링(160ms, channels.ts)과 별도로 여기서만 쓴다. sampleSpeech가
돌려주는 목표값(SpeechShape)을 매 프레임 이 스프링으로 평활해 채널에 합성한다. */
const SPEECH_SPRING_TAU_MS = 45;
/* ── 발화 동반층 스프링(결정문 §5.5 "발화가 끝나면 delta가 튀지 않게... 짧은 스프링으로
되돌린다") ── coSpeech.ts의 순수 표본은 진입·이탈 스프링을 갖지 않으므로, speaking이 아니거나
reduced motion이면 목표를 0으로 두고 여기서 짧게 수렴시킨다. */
const CO_SPEECH_SPRING_TAU_MS = 150;
const CO_SPEECH_CHANNELS: readonly ChannelId[] = [
"headYaw",
"headPitch",
"headRoll",
"bodyLean",
"shoulderRaise",
"browLY",
"browRY",
"gazeX",
"gazeY",
];
interface SpringState {
value: number;
velocity: number;
}
function zeroSpring(): SpringState {
return { value: 0, velocity: 0 };
}
/** 임계 감쇠 2차 시스템의 정확해. omega = 1/tauMs. */
function springTowards(state: SpringState, target: number, dt: number, tauMs: number): SpringState {
if (tauMs <= 0 || dt <= 0) return { value: target, velocity: 0 };
const omega = 1 / tauMs;
const diff = state.value - target;
const temp = (state.velocity + omega * diff) * dt;
const decay = Math.exp(-omega * dt);
return {
value: target + (diff + temp) * decay,
velocity: (state.velocity - omega * temp) * decay,
};
}
function filteredPresetDelta(expression: AvatarExpression, intensity: number, demeanor: Demeanor): ChannelDelta {
const preset = EXPRESSION_PRESETS[expression];
const gain = intensityGain(intensity) * demeanor.expressivity;
const out: ChannelDelta = {};
for (const id of Object.keys(preset) as ChannelId[]) {
const threshold = OVERLAY_MIN_INTENSITY[id];
if (threshold !== undefined && intensity < threshold) continue;
out[id] = (preset[id] as number) * gain;
}
return out;
}
function blendDelta(a: ChannelDelta, b: ChannelDelta, w: number): ChannelDelta {
const out: ChannelDelta = { ...a };
for (const id of CHANNEL_IDS) {
const av = a[id] ?? 0;
const bv = b[id] ?? 0;
const v = av * (1 - w) + bv * w;
if (v !== 0 || av !== 0 || bv !== 0) out[id] = v;
}
return out;
}
export function createAvatarEngine(options: AvatarEngineOptions): AvatarEngine {
const { demeanor } = options;
const reducedMotion = options.reducedMotion ?? false;
const rng: Rng = createRng(options.seed);
let currentState: AvatarState = "idle";
let openness = 0;
let surfaceExpr: AvatarExpression = "neutral";
let surfaceIntensity = 0;
let pendingSpeechShape: SpeechShape | null = null;
let speechSpring = { open: zeroSpring(), wide: zeroSpring(), round: zeroSpring(), press: zeroSpring() };
let pendingSpeechMotion: ChannelDelta | null = null;
const coSpeechSpring: Partial<Record<ChannelId, SpringState>> = {};
for (const id of CO_SPEECH_CHANNELS) coSpeechSpring[id] = zeroSpring();
let leak: LeakState | null = null;
const baseVector: ChannelVector = (() => {
const out = neutralVector();
for (const id of Object.keys(demeanor.basePose) as ChannelId[]) {
out[id] += demeanor.basePose[id] as number;
}
return out;
})();
let smoothed: ChannelVector | null = null;
let lastEvalMs: number | null = null;
let activeClips: ActiveClipEntry[] = [];
const cooldownMap = new Map<ReactionClipId, number>();
let scheduledCues: ScheduledCue[] = [];
let idleClipStates: IdleClipState[] | null = null;
let blinkState: BlinkState | null = null;
let saccadeState: SaccadeState | null = null;
function leakWeight(nowMs: number): number {
if (!leak) return 0;
const t = nowMs - leak.startAbsMs;
if (t < 0 || t > leak.durationMs) return 0;
const fade = Math.min(60, leak.durationMs / 2);
if (fade <= 0) return 1;
if (t < fade) return t / fade;
if (t > leak.durationMs - fade) return (leak.durationMs - t) / fade;
return 1;
}
function activeSuppressions(nowMs: number): Set<IdleSuppress> {
const out = new Set<IdleSuppress>();
for (const entry of activeClips) {
const clip = REACTION_CLIPS[entry.clipId];
const t = nowMs - entry.startedMs;
if (t < 0 || t > clip.durationMs) continue;
for (const s of clip.suppress ?? []) out.add(s);
}
return out;
}
function triggerClip(id: ReactionClipId, nowMs: number, opts: TriggerClipOptions = {}): boolean {
if (reducedMotion) return false;
const clip = REACTION_CLIPS[id];
const last = cooldownMap.get(id);
if (last !== undefined && nowMs - last < clip.cooldownMs) return false;
cooldownMap.set(id, nowMs);
let mirror = opts.mirror ?? false;
if (clip.mirrorable && opts.mirror === undefined) {
mirror = rng.next() < 0.5;
}
const nonEvicting = activeClips.filter((e) => e.evictAt === undefined);
if (nonEvicting.length >= 2) {
let victim = nonEvicting[0];
for (const e of nonEvicting) {
const vClip = REACTION_CLIPS[victim.clipId];
const eClip = REACTION_CLIPS[e.clipId];
if (eClip.priority < vClip.priority || (eClip.priority === vClip.priority && e.startedMs < victim.startedMs)) {
victim = e;
}
}
victim.evictAt = nowMs;
}
activeClips.push({
clipId: id,
startedMs: nowMs,
weight: opts.weight ?? 1,
mirror,
priority: clip.priority,
});
return true;
}
function setState(state: AvatarState, nowMs: number): void {
const prev = currentState;
currentState = state;
if (idleClipStates && (state === "idle" || state === "listening") && prev !== state) {
demeanor.idleClips.forEach((rule, i) => {
if (idleClipStates![i].nextAtMs < nowMs) {
idleClipStates![i] = { nextAtMs: nowMs + rng.range(rule.everyMs[0], rule.everyMs[1]) };
}
});
}
}
function setOpenness(o: number): void {
openness = o;
}
function setSurface(expression: AvatarExpression, intensity: number): void {
surfaceExpr = expression;
surfaceIntensity = intensity;
}
function setSpeechShape(shape: SpeechShape | null): void {
pendingSpeechShape = shape;
}
function setSpeechMotion(delta: ChannelDelta | null): void {
pendingSpeechMotion = delta;
}
function requestSpeechBlink(nowMs: number): void {
if (reducedMotion) return;
if (blinkState === null) blinkState = { nextAt: nowMs, startedAt: null };
/* 지금 깜빡임을 재생한다. 자연 완료 시 applyBlink가 다음 idle 타이머를 다시 뽑으므로
(§5.5 "idle 깜빡임 타이머를 다시 잡는다") 여기서 nextAt을 따로 계산하지 않는다. */
blinkState.startedAt = nowMs;
}
function playPerformance(perf: Performance, timeline: PerformanceTimeline, nowMs: number): void {
scheduledCues = [];
setSurface(perf.surface.expression, perf.surface.intensity);
leak = perf.leak
? {
expression: perf.leak.expression,
intensity: perf.leak.intensity,
startAbsMs: timeline.speechStartMs + perf.leak.atMs,
durationMs: perf.leak.durationMs,
}
: null;
for (const cue of perf.cues) {
let atAbs: number;
switch (cue.anchor) {
case "pre":
atAbs = timeline.speechStartMs + cue.at;
break;
case "post":
atAbs = timeline.speechStartMs + timeline.speechDurationMs + cue.at;
break;
case "inline":
atAbs = timeline.speechStartMs + timeline.speechDurationMs * cue.at;
break;
case "listen":
default:
atAbs = nowMs + cue.at;
break;
}
scheduledCues.push({ clip: cue.clip, atMs: atAbs, weight: cue.weight ?? 1, source: cue.source });
}
}
function computeExpressionTarget(nowMs: number): ChannelVector {
const surfaceDelta = filteredPresetDelta(surfaceExpr, surfaceIntensity, demeanor);
const w = leakWeight(nowMs);
const delta = w > 0 && leak ? blendDelta(surfaceDelta, filteredPresetDelta(leak.expression, leak.intensity, demeanor), w) : surfaceDelta;
const out: ChannelVector = { ...baseVector };
for (const id of Object.keys(delta) as ChannelId[]) {
out[id] += delta[id] as number;
}
return out;
}
function updateSmoothing(nowMs: number): void {
const target = computeExpressionTarget(nowMs);
if (smoothed === null || reducedMotion) {
smoothed = target;
return;
}
const dt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
const next = {} as ChannelVector;
for (const id of CHANNEL_IDS) {
const tau = CHANNEL_SPECS[id].tauMs;
if (tau <= 0) {
next[id] = target[id];
continue;
}
const alpha = 1 - Math.exp(-dt / tau);
next[id] = smoothed[id] + (target[id] - smoothed[id]) * alpha;
}
smoothed = next;
}
function processScheduledCues(nowMs: number): void {
const remaining: ScheduledCue[] = [];
for (const sc of scheduledCues) {
if (nowMs >= sc.atMs) {
triggerClip(sc.clip, nowMs, { weight: sc.weight });
} else {
remaining.push(sc);
}
}
scheduledCues = remaining;
}
function reactionLayerDelta(nowMs: number): ChannelDelta {
const out: ChannelDelta = {};
for (const entry of activeClips) {
const clip = REACTION_CLIPS[entry.clipId];
const t = nowMs - entry.startedMs;
if (t < 0 || t > clip.durationMs) continue;
let mult = 1;
if (entry.evictAt !== undefined) {
const et = nowMs - entry.evictAt;
mult = et >= EVICT_FADE_MS ? 0 : 1 - et / EVICT_FADE_MS;
}
const delta = resolveClipDelta(clip, t, { weight: entry.weight * mult, mirror: entry.mirror });
for (const id of Object.keys(delta) as ChannelId[]) {
out[id] = (out[id] ?? 0) + (delta[id] as number);
}
}
activeClips = activeClips.filter((entry) => {
const clip = REACTION_CLIPS[entry.clipId];
const t = nowMs - entry.startedMs;
if (entry.evictAt !== undefined && nowMs - entry.evictAt >= EVICT_FADE_MS) return false;
if (t > clip.durationMs) return false;
return true;
});
return out;
}
function processIdleClips(nowMs: number): void {
if (currentState !== "idle" && currentState !== "listening") return;
if (idleClipStates === null) {
idleClipStates = demeanor.idleClips.map((rule) => ({
nextAtMs: nowMs + rng.range(rule.everyMs[0], rule.everyMs[1]),
}));
}
demeanor.idleClips.forEach((rule, i) => {
const st = idleClipStates![i];
if (nowMs < st.nextAtMs) return;
const stateOk = rule.when === undefined || rule.when === currentState;
const opennessOk = rule.maxOpenness === undefined || openness < rule.maxOpenness;
if (stateOk && opennessOk) {
triggerClip(rule.clip, nowMs, { weight: 1 });
}
idleClipStates![i] = { nextAtMs: nowMs + rng.range(rule.everyMs[0], rule.everyMs[1]) };
});
}
function applyBlink(out: ChannelVector, nowMs: number, suppressed: boolean): void {
if (suppressed) return;
if (blinkState === null) {
blinkState = { nextAt: nowMs + rng.range(demeanor.blinkIntervalMs[0], demeanor.blinkIntervalMs[1]), startedAt: null };
}
if (blinkState.startedAt === null && nowMs >= blinkState.nextAt) {
blinkState.startedAt = nowMs;
}
let mult = 1;
if (blinkState.startedAt !== null) {
const t = nowMs - blinkState.startedAt;
if (t >= demeanor.blinkDurationMs) {
blinkState.startedAt = null;
blinkState.nextAt = nowMs + rng.range(demeanor.blinkIntervalMs[0], demeanor.blinkIntervalMs[1]);
} else {
const phase = 1 - Math.abs(t / demeanor.blinkDurationMs - 0.5) * 2;
mult = 1 - phase * 0.95;
if (mult < 0.05) mult = 0.05;
}
}
out.eyeOpenL *= mult;
out.eyeOpenR *= mult;
}
function applySaccade(out: ChannelVector, nowMs: number, suppressed: boolean): void {
if (suppressed) return;
if (saccadeState === null) {
saccadeState = { nextAt: nowMs, targetX: 0, targetY: 0, curX: 0, curY: 0 };
}
const intervalMul = currentState === "thinking" ? 0.5 : 1;
if (nowMs >= saccadeState.nextAt) {
const amp = demeanor.saccadeAmplitude * (currentState === "listening" ? 0.5 : 1);
saccadeState.targetX = rng.range(-amp, amp);
saccadeState.targetY = rng.range(-amp * 0.6, amp * 0.6);
saccadeState.nextAt =
nowMs + rng.range(demeanor.saccadeIntervalMs[0] * intervalMul, demeanor.saccadeIntervalMs[1] * intervalMul);
}
const dt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
const alpha = 1 - Math.exp(-dt / 90);
saccadeState.curX += (saccadeState.targetX - saccadeState.curX) * alpha;
saccadeState.curY += (saccadeState.targetY - saccadeState.curY) * alpha;
out.gazeX += saccadeState.curX;
out.gazeY += saccadeState.curY;
}
function applyBreath(out: ChannelVector, nowMs: number): void {
const period =
currentState === "listening" ? 4800 : currentState === "speaking" ? 3600 : currentState === "thinking" ? 5200 : 4200;
const breath = 0.5 + 0.5 * Math.sin((2 * Math.PI * nowMs) / period);
out.breath = breath;
out.shoulderRaise += (breath - 0.5) * 0.06;
}
function applySway(out: ChannelVector, nowMs: number, suppressed: boolean): void {
if (suppressed) return;
const roll = demeanor.swayAmplitude * 0.04 * (Math.sin(nowMs / 2300) + 0.6 * Math.sin(nowMs / 3700));
out.bodyRoll += roll;
out.headRoll += roll / 2;
}
function applyIdleLayer(out: ChannelVector, nowMs: number): void {
processIdleClips(nowMs);
const suppress = activeSuppressions(nowMs);
applyBlink(out, nowMs, suppress.has("blink"));
applySaccade(out, nowMs, suppress.has("saccade"));
applyBreath(out, nowMs);
applySway(out, nowMs, suppress.has("sway"));
}
function evaluate(nowMs: number): ChannelVector {
processScheduledCues(nowMs);
updateSmoothing(nowMs);
const out: ChannelVector = { ...(smoothed as ChannelVector) };
const reaction = reducedMotion ? {} : reactionLayerDelta(nowMs);
for (const id of Object.keys(reaction) as ChannelId[]) {
out[id] += reaction[id] as number;
}
if (!reducedMotion) applyIdleLayer(out, nowMs);
/* 발화층(결정문 §5.4 "엔진 합성"). 스프링은 speaking이 아닐 때도 X(닫힘)로
계속 수렴시켜 상태 전환 시 값이 멈춰 남지 않게 한다. reduced motion이어도
발화 입은 움직인다(기존 동작 유지 — idle·반응층만 끄는 대상이다). */
const suppressLipsync = activeSuppressions(nowMs).has("lipsync");
const speechTarget = currentState === "speaking" && pendingSpeechShape && !suppressLipsync ? pendingSpeechShape : SILENT_SHAPE;
const speechDt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
speechSpring = {
open: springTowards(speechSpring.open, speechTarget.open, speechDt, SPEECH_SPRING_TAU_MS),
wide: springTowards(speechSpring.wide, speechTarget.wide, speechDt, SPEECH_SPRING_TAU_MS),
round: springTowards(speechSpring.round, speechTarget.round, speechDt, SPEECH_SPRING_TAU_MS),
press: springTowards(speechSpring.press, speechTarget.press, speechDt, SPEECH_SPRING_TAU_MS),
};
if (currentState === "speaking") {
const s = speechSpring;
out.mouthOpen = s.open.value + 0.25 * out.mouthOpen;
out.mouthStretch += s.wide.value;
out.mouthRound = Math.max(out.mouthRound, s.round.value);
out.mouthPress = Math.max(0.5 * out.mouthPress, s.press.value);
out.mouthForm *= 1 - 0.5 * s.round.value;
}
/* 발화 동반층(결정문 §5.5). reduced motion이면 아예 끈다(입·턱만 남는다). speaking이고
pending delta가 있으면 계획 곡선을 그대로 통과시킨다 — coSpeech.ts의 강세 박(rise 120·
fall 260)은 이미 매끄러운 올림 코사인이라, 여기서 또 τ150ms 스프링을 걸면 정점이 깎이고
100ms 넘게 늦어져 "모음 목표 − 80ms 정점"이 무너진다(팀 리드 수정 요청, 2026-10-01).
스프링 상태는 통과 중에도 값만 동기화해 두어(속도 0), speaking이 꺼지거나 delta가 null이
되는 순간부터는 그 값에서 이어서 τ150ms로 0으로 되돌아간다(발화 종료 시 튀지 않게). */
if (!reducedMotion) {
const coSpeaking = currentState === "speaking";
const coDt = Math.max(0, nowMs - (lastEvalMs ?? nowMs));
for (const id of CO_SPEECH_CHANNELS) {
if (coSpeaking && pendingSpeechMotion) {
const v = pendingSpeechMotion[id] ?? 0;
coSpeechSpring[id] = { value: v, velocity: 0 };
out[id] += v;
} else {
const next = springTowards(coSpeechSpring[id] as SpringState, 0, coDt, CO_SPEECH_SPRING_TAU_MS);
coSpeechSpring[id] = next;
out[id] += next.value;
}
}
}
for (const id of CHANNEL_IDS) out[id] = clampChannel(id, out[id]);
lastEvalMs = nowMs;
return out;
}
function debugSnapshot(): DebugSnapshot {
const leakActive = leak !== null && lastEvalMs !== null && lastEvalMs >= leak.startAbsMs && lastEvalMs <= leak.startAbsMs + leak.durationMs;
return {
activeClips: activeClips.map((e) => ({ id: e.clipId, startedMs: e.startedMs, weight: e.weight, mirror: e.mirror })),
scheduled: scheduledCues.map((sc) => ({ clip: sc.clip, atMs: sc.atMs, source: sc.source })),
surface: { expression: surfaceExpr, intensity: surfaceIntensity },
leakActive,
};
}
return {
setState,
setOpenness,
setSurface,
setSpeechShape,
setSpeechMotion,
requestSpeechBlink,
triggerClip,
playPerformance,
evaluate,
debugSnapshot,
};
}

View file

@ -0,0 +1,68 @@
/* =====================================================================
아바타 v3 표정 프리셋 — docs/decisions/avatar-expression-engine-v3.md §5 정본 값.
각 값은 강도 1.0에서 페르소나 기본 자세 대비 delta다. FACS AU를 참고해
눈썹(AU1/2/4)·눈꺼풀(AU5/6/7/43)·입(AU12/14/15/17/20/23/24/25/26)과
머리·몸 자세를 함께 정한다. 오버레이(tear·cheek·pallor·sweat)는 강도 게이트를 따른다.
값 변경은 오케스트레이터 설계 결정이다. 구분 가능성 불변량은
scripts/check-avatar-presets.mjs 가 검증한다.
===================================================================== */
import type { AvatarExpression } from "../persona";
import { expandAuthoring, type AuthoringDelta, type ChannelDelta } from "./channels";
const PRESET_SPECS: Record<AvatarExpression, AuthoringDelta> = {
neutral: {},
/* ── positive ─────────────────────────────────────────────────── */
calm: { eyeSmile: 0.25, eyeOpen: -0.1, mouthForm: 0.2, shoulderRaise: -0.25, bodyLean: -0.05, browForm: 0.1 },
warm: { eyeSmile: 0.45, mouthForm: 0.45, browY: 0.1, headRoll: 0.12, bodyLean: 0.15 },
joy: { eyeSmile: 0.75, mouthForm: 0.85, mouthOpen: 0.35, browY: 0.2, cheek: 0.4, headPitch: 0.08, bodyLean: 0.1 },
delight: { eyeOpen: 0.2, eyeSmile: 0.5, mouthForm: 0.75, mouthOpen: 0.5, browY: 0.45, bodyLean: 0.2, headPitch: 0.1, cheek: 0.3 },
relief: { eyeOpen: -0.4, eyeSmile: 0.3, mouthForm: 0.35, browY: 0.15, browAngle: 0.2, shoulderRaise: -0.45, headPitch: -0.05 },
hopeful: { eyeOpen: 0.08, browY: 0.3, browAngle: 0.25, mouthForm: 0.25, gazeY: 0.25, headPitch: 0.12, bodyLean: 0.1 },
determined: { browY: -0.15, browX: -0.2, lidTension: 0.3, eyeOpen: 0.05, mouthPress: 0.35, mouthForm: 0.1, headPitch: -0.05, bodyLean: 0.2, shoulderRaise: -0.1 },
/* ── negative ─────────────────────────────────────────────────── */
sad: { eyeOpen: -0.25, browAngle: 0.6, browY: 0.1, mouthForm: -0.45, gazeY: -0.35, headPitch: -0.2, shoulderRaise: -0.2, chinRaise: 0.2, tear: 0.5 },
/* 비탄 눈썹은 AU1+AU4: 안쪽만 오르고 눈썹 전체는 눌려 모이며 바깥이 처진다. browY +0.2였을 때
소유자가 "눈썹이 너무 위, 두 배는 더 내려와야"라고 봐서 안쪽 올림을 절반(약 57 → 27px)으로 줄였다. */
grief: { eyeOpen: -0.45, browAngle: 0.8, browY: -0.4, browX: -0.25, browForm: 0.3, mouthForm: -0.7, mouthOpen: 0.2, chinRaise: 0.55, gazeY: -0.5, headPitch: -0.35, shoulderRaise: 0.2, tear: 0.9 },
lonely: { eyeOpen: -0.2, browAngle: 0.4, mouthForm: -0.2, gazeX: 0.45, gazeY: -0.2, headYaw: 0.18, headPitch: -0.12, shoulderRaise: -0.3 },
/* 불안은 렌더 구분도가 가장 낮았다(2단계-B-1d out4 face96 G의 0.44). 눈썹을 더 올려 모으고
눈을 더 뜨고 입술을 누르며 턱을 긴장시키고 고개를 움츠린다(AU1+2+4·5·24·17). */
anxious: { eyeOpen: 0.22, lidTension: 0.3, browAngle: 0.6, browX: -0.45, browY: 0.15, mouthPress: 0.45, mouthStretch: 0.25, chinRaise: 0.2, gazeX: -0.2, pupilSize: 0.25, headPitch: -0.06, shoulderRaise: 0.35, bodyLean: -0.1 },
panic: { eyeOpen: 0.3, browAngle: 0.55, browY: 0.45, browX: -0.2, mouthStretch: 0.6, mouthOpen: 0.3, pupilSize: 0.5, shoulderRaise: 0.6, bodyLean: -0.25, pallor: 0.6, sweat: 0.5 },
startled: { eyeOpen: 0.3, browY: 0.6, browAngle: 0.1, mouthOpen: 0.45, pupilSize: 0.3, headPitch: 0.12, bodyLean: -0.2, shoulderRaise: 0.4 },
embarrassed: { eyeSmile: 0.2, mouthForm: 0.25, mouthAsym: 0.3, gazeX: 0.5, gazeY: -0.35, headPitch: -0.18, headRoll: -0.1, cheek: 0.7 },
ashamed: { eyeOpen: -0.35, browAngle: 0.35, gazeY: -0.75, headPitch: -0.45, mouthPress: 0.35, mouthForm: -0.2, shoulderRaise: 0.3, bodyLean: -0.1, cheek: 0.45 },
guilty: { eyeOpen: -0.25, browAngle: 0.5, browX: -0.15, gazeY: -0.55, gazeX: -0.25, headPitch: -0.25, mouthPress: 0.45, mouthForm: -0.25, chinRaise: 0.25 },
overwhelmed: { eyeOpen: -0.3, lidTension: 0.25, browAngle: 0.7, browX: -0.3, mouthForm: -0.4, mouthOpen: 0.12, chinRaise: 0.35, headPitch: -0.3, shoulderRaise: 0.45, gazeY: -0.3, tear: 0.4 },
/* ── defensive ────────────────────────────────────────────────── */
resistant: { eyeOpen: -0.12, lidTension: 0.3, browY: -0.2, browX: -0.25, mouthPress: 0.5, mouthAsym: 0.2, headYaw: -0.18, bodyLean: -0.3, headPitch: 0.08, gazeX: 0.3 },
guarded: { eyeOpen: -0.08, lidTension: 0.4, browY: -0.1, mouthPress: 0.4, bodyLean: -0.2, shoulderRaise: 0.25, gazeX: 0.2, headPitch: -0.05 },
skeptical: { browLY: 0.45, browRY: -0.25, lidTension: 0.35, mouthAsym: 0.45, mouthPress: 0.2, headRoll: -0.15, headPitch: 0.1, bodyLean: -0.1 },
irritated: { browY: -0.35, browX: -0.45, browAngle: -0.3, lidTension: 0.4, mouthPress: 0.45, mouthForm: -0.15, gazeX: 0.25, headPitch: 0.05 },
angry: { browY: -0.6, browX: -0.65, browAngle: -0.6, lidTension: 0.65, eyeOpen: 0.1, mouthPress: 0.6, mouthForm: -0.3, headPitch: -0.12, bodyLean: 0.15, cheek: 0.2 },
rage: { browY: -0.8, browX: -0.8, browAngle: -0.8, lidTension: 0.5, eyeOpen: 0.25, mouthOpen: 0.45, mouthStretch: 0.35, mouthForm: -0.45, headPitch: -0.1, bodyLean: 0.3, shoulderRaise: 0.3, cheek: 0.55 },
/* ── cognitive ────────────────────────────────────────────────── */
confused: { browLY: 0.4, browRY: -0.2, browX: -0.2, eyeOpen: -0.05, headRoll: 0.2, mouthAsym: 0.25, mouthPress: 0.15, gazeY: 0.15 },
conflicted: { browX: -0.35, browAngle: 0.3, mouthPress: 0.4, mouthAsym: -0.2, gazeX: 0.3, gazeY: -0.2, headPitch: -0.08, lidTension: 0.2 },
/* ── energy ───────────────────────────────────────────────────── */
tired: { eyeOpen: -0.45, browY: -0.1, browAngle: 0.1, mouthForm: -0.1, mouthOpen: 0.05, headPitch: -0.2, headRoll: 0.1, shoulderRaise: -0.45, gazeY: -0.2 },
bored: { eyeOpen: -0.35, gazeX: 0.55, gazeY: 0.25, mouthForm: -0.15, mouthAsym: 0.15, headRoll: 0.18, bodyLean: -0.15, browY: -0.05 },
};
export const EXPRESSION_PRESETS: Record<AvatarExpression, ChannelDelta> = Object.fromEntries(
Object.entries(PRESET_SPECS).map(([id, spec]) => [id, expandAuthoring(spec)]),
) as Record<AvatarExpression, ChannelDelta>;
/** 강도 → 적용 비율(결정문 §5.2). 0~0.35 절제, 0.35~0.7 중간, 0.7~1.0 뚜렷. */
export function intensityGain(intensity: number): number {
const x = intensity < 0 ? 0 : intensity > 1 ? 1 : intensity;
if (x <= 0.35) return (x / 0.35) * 0.3;
if (x <= 0.7) return 0.3 + ((x - 0.35) / 0.35) * 0.45;
return 0.75 + ((x - 0.7) / 0.3) * 0.25;
}

View file

@ -0,0 +1,681 @@
/* =====================================================================
아바타 v3 발화층 — 한글 비짐 립싱크. 결정문 §5.4 발화층 정본.
렌더러·엔진과 무관한 순수 모듈이다. 한글 분해는 유니코드 산술로 하고
음운 규칙(g2p)은 쓰지 않는다(구분은 양순음 여부뿐, §5.4).
흐름: buildSpeechTimeline(텍스트 [+포락선]) → SpeechTimeline(키 목록)
→ 재생 중 매 프레임 sampleSpeech(timeline, t)로 표본.
임계 감쇠 스프링(τ 45ms)은 여기 두지 않는다 — 엔진의 발화층이 프레임 dt를
가지고 있으므로 engine.ts가 sampleSpeech 결과를 스프링으로 평활한다.
===================================================================== */
import { detectVoicedSegments, sampleEnvelope, type SpeechEnvelope, type VoicedSegment } from "./speechEnvelope";
export type VisemeId = "X" | "M" | "A" | "EO" | "O" | "U" | "EU" | "I" | "E";
export interface SpeechShape {
open: number;
wide: number;
round: number;
press: number;
}
export interface SpeechKey {
/** 발화 시작 기준 절대 ms. */
atMs: number;
shape: SpeechShape;
/** 디버그·검증용 비짐 이름. 렌더러는 shape만 쓴다. */
viseme: VisemeId;
}
export interface SpeechTimeline {
keys: SpeechKey[];
totalDurationMs: number;
/** 오디오 선분석 경로에서만 채워진다(표본 시 진폭 조절·무음 판정에 쓴다). */
envelope?: SpeechEnvelope;
voicedSegments?: VoicedSegment[];
/** 구 단위로 정렬한 창(진단·검증용, §5.4 "구 경계"). */
phraseWindows?: VoicedSegment[];
/** 발화 동반층(§5.5) 입력 — 구 목록(시작·끝·종류·음절별 모음 목표·강세 음절). */
phrases: SpeechPhraseInfo[];
/** 발화 동반층(§5.5) 입력 — 구 사이 휴지 구간. */
pauses: SpeechPauseWindow[];
}
/** 구 종류(§5.5): 마침표·구두점 없음 = 평서, `?` = 질문, `!` = 감탄, `,` = 이어짐. */
export type PhraseKind = "statement" | "question" | "exclaim" | "continuation";
export interface SpeechPhraseInfo {
index: number;
/** 구 시작·끝(휴지 제외) 절대 ms. */
startMs: number;
endMs: number;
kind: PhraseKind;
/** 이 구 안 음절(비한글 단위 포함)의 모음 목표 시각(§5.4 "모음 목표"), 순서대로. */
syllableTargetsMs: number[];
/** 강세 음절 인덱스(syllableTargetsMs 기준). 오름차순이며 첫 원소는 항상 0(구의 첫 음절, §5.5). */
stressedIndices: number[];
}
export interface SpeechPauseWindow {
startMs: number;
endMs: number;
}
/* ── 비짐 9종 목표값(결정문 §5.4 표) ──────────────────────────────── */
export const VISEME_SHAPE: Readonly<Record<VisemeId, SpeechShape>> = {
X: { open: 0, wide: 0, round: 0, press: 0 },
M: { open: 0, wide: 0, round: 0, press: 0.35 },
A: { open: 0.75, wide: 0.1, round: 0, press: 0 },
EO: { open: 0.55, wide: 0, round: 0.1, press: 0 },
O: { open: 0.4, wide: 0, round: 0.75, press: 0 },
U: { open: 0.2, wide: 0, round: 1.0, press: 0 },
EU: { open: 0.15, wide: 0.4, round: 0, press: 0 },
I: { open: 0.2, wide: 0.65, round: 0, press: 0 },
E: { open: 0.45, wide: 0.45, round: 0, press: 0 },
};
/** X(쉼) 목표 — 무음 판정·타임라인 끝맺음에 쓴다. */
export const SILENT_SHAPE: SpeechShape = VISEME_SHAPE.X;
function cloneShape(s: SpeechShape): SpeechShape {
return { ...s };
}
function lerpShape(a: SpeechShape, b: SpeechShape, t: number): SpeechShape {
return {
open: a.open + (b.open - a.open) * t,
wide: a.wide + (b.wide - a.wide) * t,
round: a.round + (b.round - a.round) * t,
press: a.press + (b.press - a.press) * t,
};
}
function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
function smoothstep(u: number): number {
const c = clamp(u, 0, 1);
return c * c * (3 - 2 * c);
}
/* ── 한글 분해(유니코드 산술) — 결정문 §5.4 "한글 분해는 유니코드 산술로 한다" ──── */
const HANGUL_BASE = 0xac00;
const HANGUL_LAST = 0xd7a3;
const JUNGSEONG_COUNT = 21;
const JONGSEONG_COUNT = 28;
function isHangulSyllable(code: number): boolean {
return code >= HANGUL_BASE && code <= HANGUL_LAST;
}
function decompose(code: number): { cho: number; jung: number; jong: number } {
const sIndex = code - HANGUL_BASE;
const jong = sIndex % JONGSEONG_COUNT;
const jung = Math.floor(sIndex / JONGSEONG_COUNT) % JUNGSEONG_COUNT;
const cho = Math.floor(sIndex / JONGSEONG_COUNT / JUNGSEONG_COUNT);
return { cho, jung, jong };
}
/* 초성 19종(유니코드 순서: ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ). */
const BILABIAL_CHOSEONG = new Set([6, 7, 8, 17]); // ㅁㅂㅃㅍ
const SIBILANT_CHOSEONG = new Set([9, 10, 12, 13, 14]); // ㅅㅆㅈㅉㅊ
const SILENT_CHOSEONG = 11; // ㅇ
/* 종성 28종(0=받침 없음). 끝소리가 양순음(ㅁㅂㅍ)인 것 — 겹받침 ㄻ(ㅁ)·ㄿ(ㅍ)·ㅄ(ㅂ) 포함. */
const BILABIAL_JONGSEONG = new Set([16, 17, 26, 10, 14, 18]);
/* 중성 21종: 단모음은 비짐 하나, 이중모음은 [활음, 뒤 모음] 쌍(결정문 §5.4). */
type JungseongEntry = VisemeId | readonly [VisemeId, VisemeId];
const JUNGSEONG_TABLE: readonly JungseongEntry[] = [
"A", // 0 ㅏ
"E", // 1 ㅐ
["I", "A"], // 2 ㅑ
["I", "E"], // 3 ㅒ
"EO", // 4 ㅓ
"E", // 5 ㅔ
["I", "EO"], // 6 ㅕ
["I", "E"], // 7 ㅖ
"O", // 8 ㅗ
["U", "A"], // 9 ㅘ
["U", "E"], // 10 ㅙ
["U", "E"], // 11 ㅚ(특칙: U→E)
["I", "O"], // 12 ㅛ
"U", // 13 ㅜ
["U", "EO"], // 14 ㅝ
["U", "E"], // 15 ㅞ
["U", "I"], // 16 ㅟ(특칙: U→I)
["I", "U"], // 17 ㅠ
"EU", // 18 ㅡ
["EU", "I"], // 19 ㅢ(특칙: EU→I)
"I", // 20 ㅣ
];
function jungseongVisemes(jung: number): { glide: VisemeId | null; target: VisemeId } {
const entry = JUNGSEONG_TABLE[jung];
return typeof entry === "string" ? { glide: null, target: entry } : { glide: entry[0], target: entry[1] };
}
/* ── 음절 내부 키(비짐 프랙션) 생성 ────────────────────────────────── */
interface LocalKey {
frac: number;
shape: SpeechShape;
viseme: VisemeId;
isTarget?: boolean;
}
/* 양순음 초성 + 활음이 겹칠 때 활음 키를 두는 지점(폐쇄 해제 직후). 문서에 정확한 수치가 없어
오케스트레이터 확인 전까지 쓰는 값 — "봐요" 표본(M→U→A)을 만족하는 최소치다. */
const BILABIAL_GLIDE_FRAC = 0.15;
function buildSyllableKeys(cho: number, jung: number, jong: number): LocalKey[] {
const { glide, target } = jungseongVisemes(jung);
const hasGlide = glide !== null;
const targetShape = cloneShape(VISEME_SHAPE[target]);
const targetFrac = hasGlide ? 0.55 : 0.35;
const keys: LocalKey[] = [];
if (BILABIAL_CHOSEONG.has(cho)) {
keys.push({ frac: 0, shape: cloneShape(VISEME_SHAPE.M), viseme: "M" });
if (hasGlide) {
const g = glide as VisemeId;
keys.push({ frac: BILABIAL_GLIDE_FRAC, shape: cloneShape(VISEME_SHAPE[g]), viseme: g });
}
} else if (cho === SILENT_CHOSEONG) {
if (hasGlide) {
const g = glide as VisemeId;
keys.push({ frac: 0, shape: cloneShape(VISEME_SHAPE[g]), viseme: g });
}
/* 무성 초성 + 활음 없음: 시작 키 없이 선행 음절에서 바로 이어진다(동시조음). */
} else {
const baseId = hasGlide ? (glide as VisemeId) : target;
const baseShape = VISEME_SHAPE[baseId];
let shape: SpeechShape = { ...baseShape, open: baseShape.open * 0.5 };
if (SIBILANT_CHOSEONG.has(cho)) {
shape = { ...shape, open: Math.min(shape.open, 0.15), wide: shape.wide + 0.2 };
}
keys.push({ frac: 0, shape, viseme: baseId });
}
keys.push({ frac: targetFrac, shape: targetShape, viseme: target, isTarget: true });
if (jong !== 0) {
if (BILABIAL_JONGSEONG.has(jong)) {
keys.push({ frac: 0.85, shape: cloneShape(VISEME_SHAPE.M), viseme: "M" });
} else {
keys.push({ frac: 0.85, shape: { ...targetShape, open: targetShape.open * 0.6 }, viseme: target });
}
}
return keys;
}
function otherUnitKeys(): LocalKey[] {
/* 한글이 아닌 글자(숫자·영문)는 한 음절 EO로 친다(결정문 §5.4). */
return [{ frac: 0.35, shape: cloneShape(VISEME_SHAPE.EO), viseme: "EO", isTarget: true }];
}
/* ── 텍스트 → 구 단위 토큰화(휴지 구분, 결정문 §5.4 타이밍) ────────────── */
type Unit =
| { kind: "syllable"; cho: number; jung: number; jong: number; weight: number }
| { kind: "other"; weight: number };
interface Phrase {
units: Unit[];
/** 이 구 다음에 오는 휴지(ms). 마지막 구가 구두점으로 끝나지 않으면 0. */
restMs: number;
kind: PhraseKind;
}
/* voice.py _STAGE_DIRECTION_RE / performance.ts BRACKET_RE 와 같은 괄호 문자 집합. */
const BRACKET_RE = /[(([【][^))\]】]*[))\]】]/g;
const PAUSE_COMMA_MS = 250;
const PAUSE_SENTENCE_MS = 450;
const PAUSE_ELLIPSIS_MS = 600;
function tokenizeToPhrases(
text: string,
): Array<{ units: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }>; restMs: number; kind: PhraseKind }> {
const stripped = text.replace(BRACKET_RE, " ");
const normalized = stripped.replace(/\.{2,}/g, "…");
const phrases: Array<{
units: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }>;
restMs: number;
kind: PhraseKind;
}> = [];
let current: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }> = [];
const flush = (restMs: number, kind: PhraseKind) => {
phrases.push({ units: current, restMs, kind });
current = [];
};
for (const ch of normalized) {
if (/\s/.test(ch)) continue;
const code = ch.codePointAt(0) ?? 0;
if (isHangulSyllable(code)) {
const { cho, jung, jong } = decompose(code);
current.push({ kind: "syllable", cho, jung, jong });
continue;
}
if (ch === "…") {
/* 말줄임표는 문장을 맺지 않는 망설임이라 이어짐(§5.5 구 끝 규칙에서 제외)으로 다룬다.
§5.5 표는 이 경우를 명시하지 않아 오케스트레이터 확인 전까지 쓰는 값이다. */
flush(PAUSE_ELLIPSIS_MS, "continuation");
continue;
}
if (ch === "," || ch === ",") {
flush(PAUSE_COMMA_MS, "continuation");
continue;
}
if (ch === ".") {
flush(PAUSE_SENTENCE_MS, "statement");
continue;
}
if (ch === "?") {
flush(PAUSE_SENTENCE_MS, "question");
continue;
}
if (ch === "!") {
flush(PAUSE_SENTENCE_MS, "exclaim");
continue;
}
if (/[0-9A-Za-z]/.test(ch)) {
current.push({ kind: "other" });
continue;
}
/* 그 외 기호(물결표 등)는 발화 타이밍에 영향을 주지 않는다. */
}
if (current.length > 0) phrases.push({ units: current, restMs: 0, kind: "statement" });
return phrases;
}
function syllableWeight(jong: number, isPhraseFinal: boolean): number {
let w = 1.0;
if (jong !== 0) w += 0.25;
if (isPhraseFinal) w += 0.5;
return w;
}
function otherWeight(isPhraseFinal: boolean): number {
return isPhraseFinal ? 1.5 : 1.0;
}
function withWeights(
raw: Array<{
units: Array<{ kind: "syllable" | "other"; cho?: number; jung?: number; jong?: number }>;
restMs: number;
kind: PhraseKind;
}>,
): Phrase[] {
return raw.map(({ units, restMs, kind }) => {
const withW: Unit[] = units.map((u, i) => {
const isLast = i === units.length - 1;
const phraseFinal = isLast && restMs > 0;
if (u.kind === "syllable") {
return { kind: "syllable", cho: u.cho as number, jung: u.jung as number, jong: u.jong as number, weight: syllableWeight(u.jong as number, phraseFinal) };
}
return { kind: "other", weight: otherWeight(phraseFinal) };
});
return { units: withW, restMs, kind };
});
}
/**
* 강세 음절 선택(§5.5): 구의 첫 음절 + 구 안에서 값이 가장 큰 음절(오디오는 포락선 피크,
* 텍스트는 가중치). 구가 6음절 이상이면 이웃하지 않은 상위 2개를 추가로 고른다.
* 반환은 오름차순 인덱스이며 첫 원소는 항상 0이다.
*/
function selectStressedIndices(weights: number[]): number[] {
const n = weights.length;
if (n === 0) return [];
const selected = [0];
if (n === 1) return selected;
const extraCount = n >= 6 ? 2 : 1;
const ranked = weights
.map((w, i) => ({ i, w }))
.filter((e) => e.i !== 0)
.sort((a, b) => b.w - a.w || a.i - b.i);
for (const { i } of ranked) {
if (selected.length - 1 >= extraCount) break;
if (selected.every((s) => Math.abs(s - i) >= 2)) selected.push(i);
}
selected.sort((a, b) => a - b);
return selected;
}
function localKeysFor(unit: Unit): LocalKey[] {
return unit.kind === "syllable" ? buildSyllableKeys(unit.cho, unit.jung, unit.jong) : otherUnitKeys();
}
/* ── 레이아웃 → 최종 키 목록 ──────────────────────────────────────── */
interface LaidUnit {
startMs: number;
durMs: number;
localKeys: LocalKey[];
}
const SHORT_SYLLABLE_MS = 90;
const SHORT_SYLLABLE_PULL = 0.4;
const MIN_HOLD_MS = 60;
/** 짧은 음절(<90ms) 목표를 앞뒤 음절 평균 쪽으로 40% 끌어당긴다(결정문 §5.4). */
function applyShortSyllableAveraging(laidOut: LaidUnit[]): void {
const targets = laidOut.map((lu) => lu.localKeys.find((k) => k.isTarget) ?? null);
const originals = targets.map((k) => (k ? cloneShape(k.shape) : null));
for (let i = 0; i < laidOut.length; i++) {
if (laidOut[i].durMs >= SHORT_SYLLABLE_MS) continue;
const key = targets[i];
if (!key) continue;
const prev = i > 0 ? originals[i - 1] : null;
const next = i < laidOut.length - 1 ? originals[i + 1] : null;
if (!prev && !next) continue;
const avg = prev && next ? lerpShape(prev, next, 0.5) : ((prev ?? next) as SpeechShape);
key.shape = lerpShape(originals[i] as SpeechShape, avg, SHORT_SYLLABLE_PULL);
}
}
/** 인접 키 간격이 60ms 미만이면 뒤 키를 밀어 최소 유지를 보장한다(결정문 §5.4). */
function enforceMinHold(keys: SpeechKey[]): void {
for (let i = 1; i < keys.length; i++) {
if (keys[i].atMs - keys[i - 1].atMs < MIN_HOLD_MS) {
keys[i].atMs = keys[i - 1].atMs + MIN_HOLD_MS;
}
}
}
function finalize(
laidOut: LaidUnit[],
pauseAtMs: number[],
totalMs: number,
envelope: SpeechEnvelope | undefined,
voicedSegments: VoicedSegment[] | undefined,
phraseWindows: VoicedSegment[] | undefined,
phrases: SpeechPhraseInfo[],
pauses: SpeechPauseWindow[],
): SpeechTimeline {
applyShortSyllableAveraging(laidOut);
const keys: SpeechKey[] = [];
for (const lu of laidOut) {
for (const lk of lu.localKeys) {
keys.push({ atMs: lu.startMs + lk.frac * lu.durMs, shape: lk.shape, viseme: lk.viseme });
}
}
for (const at of pauseAtMs) {
keys.push({ atMs: at, shape: cloneShape(SILENT_SHAPE), viseme: "X" });
}
keys.sort((a, b) => a.atMs - b.atMs);
const last = keys[keys.length - 1];
if (!last || last.viseme !== "X" || last.atMs < totalMs) {
keys.push({ atMs: totalMs, shape: cloneShape(SILENT_SHAPE), viseme: "X" });
}
enforceMinHold(keys);
const finalTotal = Math.max(totalMs, keys[keys.length - 1].atMs);
return { keys, totalDurationMs: finalTotal, envelope, voicedSegments, phraseWindows, phrases, pauses };
}
/* ── 텍스트만으로 타이밍(오디오 없음, 결정문 §5.4) ────────────────────── */
function buildTextOnlyTimeline(phrases: Phrase[], syllablesPerSec: number): SpeechTimeline {
const baseDurMs = 1000 / syllablesPerSec;
const laidOut: LaidUnit[] = [];
const pauseAtMs: number[] = [];
const phraseInfos: SpeechPhraseInfo[] = [];
const pauses: SpeechPauseWindow[] = [];
let tMs = 0;
phrases.forEach((phrase, pIdx) => {
const startMs = tMs;
const syllableTargetsMs: number[] = [];
const weights: number[] = [];
for (const unit of phrase.units) {
const durMs = unit.weight * baseDurMs;
const localKeys = localKeysFor(unit);
laidOut.push({ startMs: tMs, durMs, localKeys });
const targetKey = localKeys.find((k) => k.isTarget);
syllableTargetsMs.push(tMs + (targetKey?.frac ?? 0.35) * durMs);
weights.push(unit.weight);
tMs += durMs;
}
phraseInfos.push({
index: pIdx,
startMs,
endMs: tMs,
kind: phrase.kind,
syllableTargetsMs,
stressedIndices: selectStressedIndices(weights),
});
if (phrase.restMs > 0) {
pauseAtMs.push(tMs);
pauses.push({ startMs: tMs, endMs: tMs + phrase.restMs });
tMs += phrase.restMs;
}
});
return finalize(laidOut, pauseAtMs, tMs, undefined, undefined, undefined, phraseInfos, pauses);
}
/* ── 오디오 선분석 정렬(결정문 §5.4) ─────────────────────────────────── */
const CORE_SNAP_RATIO = 0.35;
function findEnvelopePeakMs(envelope: SpeechEnvelope, fromMs: number, toMs: number): number | null {
if (toMs <= fromMs) return null;
const i0 = Math.max(0, Math.floor(fromMs / envelope.hopMs));
const i1 = Math.min(envelope.rms.length - 1, Math.ceil(toMs / envelope.hopMs));
let bestI = -1;
let bestV = -Infinity;
for (let i = i0; i <= i1; i++) {
if (envelope.rms[i] > bestV) {
bestV = envelope.rms[i];
bestI = i;
}
}
return bestI >= 0 ? bestI * envelope.hopMs : null;
}
/**
* 구 수(N)와 유성 구간 수(M)를 맞춘다(결정문 §5.4).
* M > N이면 가장 짧은 틈부터 합친다. M < N이면 가장 긴 구간을 분할한다(중간점 분할로 단순화 —
* "구 가중치 비율로 나눈다"의 근사치. 여러 구가 한 구간에 몰리는 경우까지 정확히 풀려면
* 별도의 배정 문제가 필요해 단순화했다. 자세한 사유는 작업 보고 참고).
*/
function reconcileSegments(segments: VoicedSegment[], phraseCount: number): VoicedSegment[] {
if (phraseCount === 0) return [];
let segs = segments.map((s) => ({ ...s }));
if (segs.length === 0) {
return Array.from({ length: phraseCount }, (_, i) => ({ startMs: i * 200, endMs: i * 200 + 150 }));
}
while (segs.length > phraseCount) {
let minGap = Infinity;
let idx = 0;
for (let i = 0; i < segs.length - 1; i++) {
const gap = segs[i + 1].startMs - segs[i].endMs;
if (gap < minGap) {
minGap = gap;
idx = i;
}
}
segs[idx] = { startMs: segs[idx].startMs, endMs: segs[idx + 1].endMs };
segs.splice(idx + 1, 1);
}
while (segs.length < phraseCount) {
let maxDur = -1;
let idx = 0;
for (let i = 0; i < segs.length; i++) {
const dur = segs[i].endMs - segs[i].startMs;
if (dur > maxDur) {
maxDur = dur;
idx = i;
}
}
const seg = segs[idx];
const mid = seg.startMs + (seg.endMs - seg.startMs) / 2;
segs.splice(idx, 1, { startMs: seg.startMs, endMs: mid }, { startMs: mid, endMs: seg.endMs });
}
return segs;
}
function buildAudioAlignedTimeline(phrases: Phrase[], envelope: SpeechEnvelope): SpeechTimeline {
const voicedSegments = detectVoicedSegments(envelope);
const phraseWeights = phrases.map((p) => p.units.reduce((s, u) => s + u.weight, 0));
const windows = reconcileSegments(voicedSegments, phrases.length);
const laidOut: LaidUnit[] = [];
const pauseAtMs: number[] = [];
const phraseInfos: SpeechPhraseInfo[] = [];
const pauses: SpeechPauseWindow[] = [];
for (let i = 0; i < phrases.length; i++) {
const phrase = phrases[i];
const win = windows[i];
if (!win || phrase.units.length === 0) {
phraseInfos.push({
index: i,
startMs: win?.startMs ?? 0,
endMs: win?.endMs ?? 0,
kind: phrase.kind,
syllableTargetsMs: [],
stressedIndices: [],
});
continue;
}
const totalWeight = phraseWeights[i] || 1;
const winDur = Math.max(1, win.endMs - win.startMs);
let acc = win.startMs;
const syllableTargetsMs: number[] = [];
const ampWeights: number[] = [];
for (const unit of phrase.units) {
const slotDur = winDur * (unit.weight / totalWeight);
const slotStart = acc;
acc += slotDur;
const nominalCenter = slotStart + slotDur / 2;
const searchLo = Math.max(win.startMs, nominalCenter - slotDur * CORE_SNAP_RATIO);
const searchHi = Math.min(win.endMs, nominalCenter + slotDur * CORE_SNAP_RATIO);
const peakMs = findEnvelopePeakMs(envelope, searchLo, searchHi);
const startMs = peakMs === null ? slotStart : slotStart + (peakMs - nominalCenter);
const localKeys = localKeysFor(unit);
laidOut.push({ startMs, durMs: slotDur, localKeys });
const targetKey = localKeys.find((k) => k.isTarget);
const targetMs = startMs + (targetKey?.frac ?? 0.35) * slotDur;
syllableTargetsMs.push(targetMs);
ampWeights.push(sampleEnvelope(envelope, targetMs));
}
phraseInfos.push({
index: i,
startMs: win.startMs,
endMs: win.endMs,
kind: phrase.kind,
syllableTargetsMs,
stressedIndices: selectStressedIndices(ampWeights),
});
if (i < windows.length - 1) {
pauseAtMs.push(win.endMs);
pauses.push({ startMs: win.endMs, endMs: windows[i + 1].startMs });
}
}
const totalMs = windows.length > 0 ? windows[windows.length - 1].endMs : 0;
return finalize(laidOut, pauseAtMs, totalMs, envelope, voicedSegments, windows, phraseInfos, pauses);
}
export interface BuildSpeechTimelineParams {
text: string;
/** 오디오가 없을 때 쓰는 발화 속도(음절/초). demeanor.speech.syllablesPerSec. */
syllablesPerSec: number;
/** 있으면 오디오 선분석 정렬 경로를 쓴다. */
envelope?: SpeechEnvelope;
}
export function buildSpeechTimeline(params: BuildSpeechTimelineParams): SpeechTimeline {
const phrases = withWeights(tokenizeToPhrases(params.text));
if (params.envelope) return buildAudioAlignedTimeline(phrases, params.envelope);
return buildTextOnlyTimeline(phrases, params.syllablesPerSec);
}
/* ── 표본(재생 중 매 프레임, 결정문 §5.4 "표본·평활") ──────────────────── */
const LOOKAHEAD_MS = 60;
const SILENCE_CLOSE_MS = 100;
const MAX_SPEAKING_OPEN = 0.6;
function isDeepSilence(segments: VoicedSegment[], tMs: number, minMs: number): boolean {
let prevEnd = 0;
for (const seg of segments) {
if (tMs >= seg.startMs && tMs <= seg.endMs) return false;
if (seg.startMs > tMs) break;
prevEnd = seg.endMs;
}
return tMs - prevEnd > minMs;
}
/**
* timeline을 tMs(재생 위치, 오디오 시계 또는 엔진 시계) 기준으로 표본한다.
* 60ms 앞당김·smoothstep 보간·짧은 음절 평균화(타임라인 빌드 시 반영됨)·크기 조절식·
* 무음 100ms 닫힘·articulation 곱·open 상한 0.6까지 여기서 끝낸다.
* 임계 감쇠 스프링(τ 45ms)은 포함하지 않는다 — engine.ts가 이 결과를 목표값으로 스프링한다.
*/
export function sampleSpeech(timeline: SpeechTimeline, tMs: number, articulation = 1): SpeechShape {
const keys = timeline.keys;
if (keys.length === 0) return cloneShape(SILENT_SHAPE);
const tQuery = tMs + LOOKAHEAD_MS;
let shape: SpeechShape;
if (tQuery <= keys[0].atMs) {
shape = cloneShape(keys[0].shape);
} else if (tQuery >= keys[keys.length - 1].atMs) {
shape = cloneShape(keys[keys.length - 1].shape);
} else {
let i = 0;
while (i < keys.length - 1 && keys[i + 1].atMs <= tQuery) i++;
const a = keys[i];
const b = keys[i + 1];
const span = b.atMs - a.atMs;
const u = span > 0 ? (tQuery - a.atMs) / span : 1;
shape = lerpShape(a.shape, b.shape, smoothstep(u));
}
if (timeline.envelope) {
const env = sampleEnvelope(timeline.envelope, tQuery);
const p90 = timeline.envelope.p90 || 1e-6;
const scale = clamp(0.45 + 0.75 * (env / p90), 0.45, 1.1);
shape = { ...shape, open: shape.open * scale };
if (timeline.voicedSegments && isDeepSilence(timeline.voicedSegments, tQuery, SILENCE_CLOSE_MS)) {
shape = cloneShape(SILENT_SHAPE);
}
}
const art = Number.isFinite(articulation) ? articulation : 1;
return {
open: Math.min(shape.open * art, MAX_SPEAKING_OPEN),
wide: shape.wide * art,
round: shape.round * art,
press: shape.press,
};
}
/** tMs 시점의 "현재 비짐" 이름(가장 최근에 지난 키). Lab 표시·디버그용. */
export function currentViseme(timeline: SpeechTimeline, tMs: number): VisemeId {
const keys = timeline.keys;
if (keys.length === 0) return "X";
const tQuery = tMs + LOOKAHEAD_MS;
if (tQuery <= keys[0].atMs) return keys[0].viseme;
let i = 0;
while (i < keys.length - 1 && keys[i + 1].atMs <= tQuery) i++;
return keys[i].viseme;
}
/** 타임라인의 비짐 이름을 순서대로(연속 중복 제거) 뽑는다. 검증·디버그용. */
export function visemeSequence(timeline: SpeechTimeline): VisemeId[] {
const out: VisemeId[] = [];
for (const k of timeline.keys) {
if (out[out.length - 1] !== k.viseme) out.push(k.viseme);
}
return out;
}

View file

@ -0,0 +1,203 @@
/* =====================================================================
아바타 v3 신호 → 공연 스크립트 — 결정문 §7 정본 규칙.
괄호 지문 파서(§7.3), 상태 유도 보조 cue(§7.4), 겉표정 강도(§7.5)를
여기서 구현한다. 렌더러는 이 모듈을 몰라도 되고, 엔진은 Performance
타입만 소비한다.
===================================================================== */
import { AVATAR_EXPRESSION_LIBRARY, type AvatarExpression } from "../persona";
import { STAGE_DIRECTION_MAX_LENGTH, STAGE_DIRECTION_RULES } from "./stageDirectionLexicon";
import { createRng, hashString } from "./rng";
import type { ReactionClipId } from "./clipCatalog";
export type PerformanceAnchor = "listen" | "pre" | "inline" | "post";
export type PerformanceCueSource = "direction" | "state" | "jev";
export interface PerformanceCue {
clip: ReactionClipId;
anchor: PerformanceAnchor;
/** anchor=inline이면 발화 텍스트 내 비율(0..1), 그 외에는 앵커 기준 지연 ms. */
at: number;
/** 클립 delta 배율(0..1). 생략하면 1. */
weight?: number;
source: PerformanceCueSource;
}
export interface Performance {
surface: { expression: AvatarExpression; intensity: number };
leak?: { expression: AvatarExpression; intensity: number; atMs: number; durationMs: number };
cues: PerformanceCue[];
}
/* voice.py _STAGE_DIRECTION_RE 와 같은 괄호 문자 집합(반각·전각 소괄호·대괄호). */
const BRACKET_RE = /[(([【][^))\]】]*[))\]】]/g;
/* 복합 지문 분리 구분자. */
const SPLIT_RE = /[,,·、]/;
/* 발화 가능 글자에서 제외하는 구두점(공백은 별도로 제외). */
const SPEAKABLE_PUNCTUATION = new Set(["…", ".", ",", "?", "!", "~"]);
function countSpeakable(text: string): number {
let count = 0;
for (const ch of text) {
if (/\s/.test(ch)) continue;
if (SPEAKABLE_PUNCTUATION.has(ch)) continue;
count++;
}
return count;
}
/**
* 괄호 지문을 cue로 파싱한다(결정문 §7.3).
* 대상은 내담자 발화 원문 전체다. 발화 가능 텍스트에서 지문 조각은 제거된 채로 남는다.
*/
export function parseStageDirections(text: string): {
cues: PerformanceCue[];
unmatched: string[];
speakableText: string;
} {
const rawCues: PerformanceCue[] = [];
const unmatched: string[] = [];
for (const match of text.matchAll(BRACKET_RE)) {
const full = match[0];
const start = match.index ?? 0;
const end = start + full.length;
const inner = full.slice(1, -1).trim();
if (inner.length > STAGE_DIRECTION_MAX_LENGTH) continue;
const beforeText = text.slice(0, start).replace(BRACKET_RE, "");
const afterText = text.slice(end).replace(BRACKET_RE, "");
const beforeCount = countSpeakable(beforeText);
const afterCount = countSpeakable(afterText);
let anchor: PerformanceAnchor;
let at: number;
if (beforeCount === 0) {
anchor = "pre";
at = 0;
} else if (afterCount === 0) {
anchor = "post";
at = 0;
} else {
anchor = "inline";
at = beforeCount / (beforeCount + afterCount);
}
const pieces = inner
.split(SPLIT_RE)
.map((piece) => piece.trim())
.filter((piece) => piece.length > 0);
for (const piece of pieces) {
const rule = STAGE_DIRECTION_RULES.find((candidate) => candidate.pattern.test(piece));
if (rule) {
rawCues.push({ clip: rule.clip, anchor, at, weight: rule.weight, source: "direction" });
} else {
unmatched.push(piece);
}
}
}
/* 같은 클립이 연달아 나오면 하나로 합친다. */
const merged: PerformanceCue[] = [];
for (const cue of rawCues) {
const prev = merged[merged.length - 1];
if (prev && prev.clip === cue.clip) continue;
merged.push(cue);
}
return {
cues: merged.slice(0, 3),
unmatched,
speakableText: text.replace(BRACKET_RE, "").trim(),
};
}
interface AuxCandidate {
clip: ReactionClipId;
anchor: PerformanceAnchor;
at: number;
weight?: number;
}
function auxCandidatesFor(group: string | undefined, intensity: number): AuxCandidate[] {
switch (group) {
case "negative":
if (intensity < 0.5) return [];
return [
{ clip: "look_down", anchor: "pre", at: 200 },
{ clip: "lip_press", anchor: "post", at: 0 },
];
case "defensive":
return [
{ clip: "look_away_side", anchor: "inline", at: 0.3 },
{ clip: "lean_back", anchor: "pre", at: 0 },
];
case "positive":
return [{ clip: "nod_small", anchor: "inline", at: 0.4 }];
case "energy":
return [{ clip: "look_down", anchor: "pre", at: 0, weight: 0.6 }];
case "cognitive":
default:
return [];
}
}
/** 겉표정 강도(결정문 §7.5, legacy 경로). */
export function surfaceIntensityFor(params: {
expression: AvatarExpression;
openness: number;
safety: boolean;
paused: boolean;
}): number {
const { expression, openness, safety, paused } = params;
if (safety) return 0.9;
if (paused) return 0.6;
const group = AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === expression)?.group;
const o = openness < 0 ? 0 : openness > 1 ? 1 : openness;
switch (group) {
case "negative":
case "defensive":
return 0.35 + 0.5 * (1 - o);
case "positive":
return 0.3 + 0.6 * o;
default:
return 0.5;
}
}
/** 지문 파싱 + 상태 유도 보조 cue(§7.4)까지 합쳐 공연 스크립트를 만든다. */
export function buildPerformance(params: {
text: string;
expression: AvatarExpression;
intensity: number;
openness: number;
seed: number;
}): { performance: Performance; unmatched: string[]; speakableText: string } {
const { text, expression, intensity, seed } = params;
const { cues, unmatched, speakableText } = parseStageDirections(text);
let finalCues = cues;
if (cues.length === 0) {
const rng = createRng(hashString(text) ^ seed);
if (rng.next() < 0.6) {
const group = AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === expression)?.group;
const candidates = auxCandidatesFor(group, intensity);
if (candidates.length > 0) {
const picked = rng.pick(candidates);
finalCues = [{ ...picked, source: "state" }];
}
}
}
return {
performance: { surface: { expression, intensity }, cues: finalCues },
unmatched,
speakableText,
};
}
/** 발화 시간 추정: max(800, 발화 가능 글자 수 × 90)ms. */
export function estimateSpeechDurationMs(speakableText: string): number {
return Math.max(800, countSpeakable(speakableText) * 90);
}

View file

@ -0,0 +1,47 @@
/* =====================================================================
아바타 v3 엔진 전용 시드 난수 — mulberry32.
엔진은 순수 함수여야 하므로(결정문 §3) Math.random을 직접 쓰지 않고
이 모듈이 주입하는 rng를 통해서만 난수를 소비한다.
===================================================================== */
export interface Rng {
/** [0,1) 균등 분포. */
next(): number;
/** [a,b) 균등 분포. */
range(a: number, b: number): number;
/** 배열에서 균등하게 하나 고른다. */
pick<T>(arr: readonly T[]): T;
}
export function createRng(seed: number): Rng {
let state = seed >>> 0;
function next(): number {
state = (state + 0x6d2b79f5) | 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
return {
next,
range(a: number, b: number): number {
return a + (b - a) * next();
},
pick<T>(arr: readonly T[]): T {
const idx = Math.min(arr.length - 1, Math.floor(next() * arr.length));
return arr[idx];
},
};
}
/** FNV-1a 32bit 문자열 해시. */
export function hashString(text: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}

View file

@ -0,0 +1,106 @@
/* =====================================================================
아바타 v3 발화 포락선 — 결정문 §5.4 발화층 "타이밍(오디오가 있으면 선분석)".
AudioBuffer 등 DOM 타입에 의존하지 않는 순수 함수만 둔다(테스트 가능해야 한다).
호출부(Lab·Session)가 AudioBuffer에서 채널 Float32Array를 꺼내 넘긴다.
===================================================================== */
export interface SpeechEnvelope {
/** RMS 표본 간격(ms). 결정문 §5.4는 10ms. */
hopMs: number;
/** hop마다 하나씩, 모노 합성 RMS. */
rms: Float32Array;
/** 진폭 조절식(§5.4 표본·평활)에 쓰는 90퍼센타일. */
p90: number;
/** 유성 구간 임계값(§5.4 타이밍)에 쓰는 95퍼센타일. */
p95: number;
}
export interface VoicedSegment {
startMs: number;
endMs: number;
}
function percentile(sortedAscending: ArrayLike<number>, p: number): number {
const n = sortedAscending.length;
if (n === 0) return 0;
const idx = Math.min(n - 1, Math.max(0, Math.ceil(p * n) - 1));
return sortedAscending[idx];
}
/**
* 오디오 채널 데이터(모노 Float32Array 1개 또는 채널별 배열)에서 10ms 간격 RMS 포락선을 만든다.
* 다채널이면 표본마다 채널 평균으로 모노 합성한다(결정문 §5.4 "AudioBuffer 모노 합성").
*/
export function computeEnvelope(
channelData: Float32Array | Float32Array[],
sampleRate: number,
hopMs = 10,
): SpeechEnvelope {
const channels = Array.isArray(channelData) ? channelData : [channelData];
const length = channels[0]?.length ?? 0;
const hopSamples = Math.max(1, Math.round((hopMs / 1000) * sampleRate));
const hopCount = Math.max(1, Math.ceil(length / hopSamples));
const rms = new Float32Array(hopCount);
for (let h = 0; h < hopCount; h++) {
const start = h * hopSamples;
const end = Math.min(length, start + hopSamples);
let sumSq = 0;
let n = 0;
for (let i = start; i < end; i++) {
let mono = 0;
for (const ch of channels) mono += ch[i] ?? 0;
mono /= channels.length;
sumSq += mono * mono;
n++;
}
rms[h] = n > 0 ? Math.sqrt(sumSq / n) : 0;
}
const sorted = Array.from(rms).sort((a, b) => a - b);
return { hopMs, rms, p90: percentile(sorted, 0.9), p95: percentile(sorted, 0.95) };
}
/**
* 유성 구간 검출(결정문 §5.4): 포락선 > max(0.02, 0.12·P95)인 구간.
* 120ms 미만 틈은 메우고, 60ms 미만 구간은 버린다.
*/
export function detectVoicedSegments(envelope: SpeechEnvelope): VoicedSegment[] {
const { hopMs, rms, p95 } = envelope;
const threshold = Math.max(0.02, 0.12 * p95);
const raw: VoicedSegment[] = [];
let curStart = -1;
for (let i = 0; i < rms.length; i++) {
const above = rms[i] > threshold;
if (above && curStart < 0) curStart = i;
if (!above && curStart >= 0) {
raw.push({ startMs: curStart * hopMs, endMs: i * hopMs });
curStart = -1;
}
}
if (curStart >= 0) raw.push({ startMs: curStart * hopMs, endMs: rms.length * hopMs });
const filled: VoicedSegment[] = [];
for (const seg of raw) {
const prev = filled[filled.length - 1];
if (prev && seg.startMs - prev.endMs < 120) {
prev.endMs = seg.endMs;
} else {
filled.push({ ...seg });
}
}
return filled.filter((seg) => seg.endMs - seg.startMs >= 60);
}
/** tMs 시점의 포락선 값(선형 보간). */
export function sampleEnvelope(envelope: SpeechEnvelope, tMs: number): number {
const { hopMs, rms } = envelope;
if (rms.length === 0) return 0;
const idx = tMs / hopMs;
const i0 = Math.max(0, Math.min(rms.length - 1, Math.floor(idx)));
const i1 = Math.min(rms.length - 1, i0 + 1);
const frac = idx - i0;
return rms[i0] + (rms[i1] - rms[i0]) * frac;
}

View file

@ -0,0 +1,69 @@
/* =====================================================================
아바타 v3 괄호 지문 어휘집 — 결정문 §7.3 정본.
근거: 실제 LLM 발화 표본(scratch/jev, P1 40턴)·golden 합성 대화(data/golden/P4~P7)·
캐릭터 카드 verbal_tics. 괄호는 지문 외 용도(인용·메모)로도 쓰이므로
어휘집에 맞는 괄호만 반응으로 바꾸고 나머지는 무시한다.
규칙은 위에서부터 검사해 처음 맞는 것 하나만 쓴다(구체 → 일반 순서 유지).
어휘 추가·순서 변경은 설계 결정이다.
===================================================================== */
import type { ReactionClipId } from "./clipCatalog";
export interface StageDirectionRule {
/** 괄호 안쪽 텍스트(앞뒤 공백 제거)에 대한 패턴. */
pattern: RegExp;
clip: ReactionClipId;
/** 클립 delta 배율(0..1). 생략하면 1. */
weight?: number;
}
/** 괄호 안쪽이 이 길이를 넘으면 지문이 아닌 것으로 보고 무시한다. */
export const STAGE_DIRECTION_MAX_LENGTH = 16;
export const STAGE_DIRECTION_RULES: readonly StageDirectionRule[] = [
/* 눈물·울음 */
{ pattern: /눈물|울먹|글썽|훌쩍|목이\s*메/, clip: "tear_up" },
/* 한숨·호흡 */
{ pattern: /(옅은|작은|짧은)\s*한숨/, clip: "sigh", weight: 0.6 },
{ pattern: /한숨/, clip: "sigh" },
{ pattern: /심호흡|숨을\s*(고르|크게|깊이)|크게\s*숨/, clip: "deep_breath" },
{ pattern: /침을\s*삼|마른침|꿀꺽/, clip: "swallow" },
/* 침묵·멈춤·주저 */
{ pattern: /침묵/, clip: "silence_hold" },
{ pattern: /멈칫|굳어|얼어/, clip: "freeze", weight: 0.7 },
{ pattern: /(잠시|잠깐)?\s*멈춤|말끝을?\s*흐리/, clip: "look_down", weight: 0.7 },
{ pattern: /머뭇|망설/, clip: "lip_press", weight: 0.6 },
/* 웃음 */
{ pattern: /피식|코웃음|쓴웃음|헛웃음|비웃/, clip: "scoff" },
{ pattern: /억지\s*(로\s*)?웃|억지\s*미소/, clip: "forced_smile" },
{ pattern: /(어색|긴장|멋쩍|머쓱)[한은]?\s*웃음|웃음/, clip: "nervous_laugh" },
/* 고개 */
{ pattern: /(크게|깊이|깊게)\s*끄덕/, clip: "nod_deep" },
{ pattern: /끄덕/, clip: "nod_small" },
{ pattern: /고개를?\s*(젓|저어|흔들)|도리도리/, clip: "head_shake" },
{ pattern: /고개를?\s*(숙|떨|푹)|고개\s*떨굼/, clip: "head_drop" },
{ pattern: /고개를?\s*(살짝\s*)?돌/, clip: "look_away_side" },
/* 시선 */
{ pattern: /시선을?\s*(피|돌)|눈을?\s*(피|돌)|딴\s*곳|창밖/, clip: "look_away_side" },
{ pattern: /시선을?\s*(내리|아래)|바닥을?\s*(보|내려)|내려다/, clip: "look_down" },
{ pattern: /힐끗|흘끗|쳐다|눈을?\s*맞/, clip: "glance_up" },
{ pattern: /눈을?\s*굴리/, clip: "eye_roll_small" },
{ pattern: /눈을?\s*(여러\s*번\s*)?깜빡/, clip: "blink_flurry" },
/* 몸 */
{ pattern: /움찔|흠칫|놀라/, clip: "flinch" },
{ pattern: /으쓱/, clip: "shrug" },
{ pattern: /뒤로\s*기대|등을?\s*기대|몸을?\s*뒤로|팔짱/, clip: "lean_back" },
{ pattern: /몸을?\s*앞으로|다가앉|앞으로\s*기울/, clip: "lean_forward" },
{ pattern: /만지작|꼼지락|안절부절|다리를?\s*떨|손톱/, clip: "fidget_sway" },
/* 입 */
{ pattern: /입술을?\s*깨물|입술\s*깨묾/, clip: "lip_bite" },
{ pattern: /이를?\s*악물|주먹을?\s*쥐/, clip: "jaw_clench" },
{ pattern: /입술을?\s*(꾹\s*)?다물|입을?\s*(꾹\s*)?다물/, clip: "lip_press" },
];

View file

@ -0,0 +1,215 @@
/* =====================================================================
DebugFace — 아바타 v3 엔진 채널 33개를 전부 눈으로 검증하는 선 기반 SVG 리그.
아트 품질이 목적이 아니라 채널→시각 매핑 확인용이다(결정문 §9 1단계).
매 프레임 React state를 갱신하지 않고 ref로 SVG 속성을 직접 쓴다.
===================================================================== */
import { useEffect, useRef } from "react";
import type { AvatarEngine } from "../engine/engine";
import type { ChannelVector } from "../engine/channels";
import "./debug-face.css";
export interface DebugFaceProps {
engine: AvatarEngine;
running: boolean;
nowMs?: () => number;
}
type Refs = Record<string, SVGGraphicsElement | null>;
const CX = 130;
const HEAD_CY = 150;
function eyePathD(cx: number, cy: number, openness: number, smile: number, lidTension: number): string {
const rx = 20;
const ry = 14;
const o = Math.max(0, Math.min(1.3, openness));
const tensionAdj = 1 - lidTension * 0.4;
const upperY = cy - ry * 1.05 * o * tensionAdj;
const lowerY = cy + ry * 0.5 * o * (1 - Math.max(0, smile) * 0.7) * tensionAdj;
return `M ${cx - rx} ${cy} Q ${cx} ${upperY} ${cx + rx} ${cy} Q ${cx} ${lowerY} ${cx - rx} ${cy} Z`;
}
function browPathD(innerX: number, outerX: number, baseY: number, y: number, angle: number, x: number, form: number): string {
const innerY = baseY - y * 18 - angle * 14;
const outerY = baseY - y * 18 + angle * 8;
const innerXAdj = innerX - x * 6;
const midX = (innerXAdj + outerX) / 2;
const midY = (innerY + outerY) / 2 - form * 10;
return `M ${outerX} ${outerY} Q ${midX} ${midY} ${innerXAdj} ${innerY}`;
}
function mouthPathD(v: ChannelVector): string {
const cy = 192;
/* mouthRound(§4 — 2026-09-30 립싱크 설계 채널): 오므린 입("ㅗ·ㅜ")을 입 폭을 줄이고
세로로 둥글려 간단히 반영한다. 정밀한 리그 규칙(§8.4)은 LinocutAvatar가 맡는다. */
const round = Math.max(0, Math.min(1, v.mouthRound));
const halfWidth = Math.max(6, 26 + v.mouthStretch * 16 - round * 14);
const leftX = CX - halfWidth;
const rightX = CX + halfWidth;
const cornerLift = v.mouthForm * 18;
const asym = v.mouthAsym * 12;
const leftCornerY = cy - cornerLift - asym;
const rightCornerY = cy - cornerLift + asym;
const restGap = Math.max(1.5, 5 - v.mouthPress * 3.2) + round * 6;
const openGap = v.mouthOpen * 26;
const upperCtrlY = cy - restGap - openGap * 0.5;
const lowerCtrlY = cy + restGap + openGap;
return `M ${leftX} ${leftCornerY} Q ${CX} ${upperCtrlY} ${rightX} ${rightCornerY} Q ${CX} ${lowerCtrlY} ${leftX} ${leftCornerY} Z`;
}
function renderFrame(refs: Refs, v: ChannelVector): void {
const body = refs.body;
if (body) {
const scaleY = 1 + v.bodyLean * 0.04;
const translateX = v.bodyYaw * 6;
body.setAttribute(
"transform",
`translate(${translateX} ${-v.bodyLean * 6}) rotate(${v.bodyRoll * 6} ${CX} 300) scale(1 ${scaleY})`,
);
}
const shoulder = refs.shoulder;
if (shoulder) shoulder.setAttribute("transform", `translate(0 ${-v.shoulderRaise * 12})`);
const head = refs.head;
if (head) {
const translateX = v.headYaw * 14;
const translateY = -v.headPitch * 14;
head.setAttribute("transform", `translate(${translateX} ${translateY}) rotate(${v.headRoll * 10} ${CX} ${HEAD_CY})`);
}
const face = refs.face;
if (face) {
const squish = 1 - Math.abs(v.headYaw) * 0.15;
face.setAttribute("transform", `translate(${v.headYaw * 4} 0) scale(${squish} 1)`);
}
const eyeLeft = refs["eye-left"] as SVGPathElement | null;
if (eyeLeft) eyeLeft.setAttribute("d", eyePathD(96, 140, v.eyeOpenL, v.eyeSmileL, v.lidTension));
const eyeRight = refs["eye-right"] as SVGPathElement | null;
if (eyeRight) eyeRight.setAttribute("d", eyePathD(164, 140, v.eyeOpenR, v.eyeSmileR, v.lidTension));
const irisLeft = refs["iris-left"];
if (irisLeft) irisLeft.setAttribute("transform", `translate(${v.gazeX * 8} ${v.gazeY * 6})`);
const irisRight = refs["iris-right"];
if (irisRight) irisRight.setAttribute("transform", `translate(${v.gazeX * 8} ${v.gazeY * 6})`);
const pupilLeft = refs["pupil-left"] as SVGCircleElement | null;
if (pupilLeft) pupilLeft.setAttribute("r", String(Math.max(1, 3 * (1 + v.pupilSize * 0.5))));
const pupilRight = refs["pupil-right"] as SVGCircleElement | null;
if (pupilRight) pupilRight.setAttribute("r", String(Math.max(1, 3 * (1 + v.pupilSize * 0.5))));
const browLeft = refs["brow-left"] as SVGPathElement | null;
if (browLeft) browLeft.setAttribute("d", browPathD(112, 72, 110, v.browLY, v.browLAngle, v.browLX, v.browForm));
const browRight = refs["brow-right"] as SVGPathElement | null;
if (browRight) browRight.setAttribute("d", browPathD(148, 188, 110, v.browRY, v.browRAngle, -v.browRX, v.browForm));
const mouth = refs.mouth as SVGPathElement | null;
if (mouth) mouth.setAttribute("d", mouthPathD(v));
const chin = refs.chin;
if (chin) chin.setAttribute("opacity", String(Math.max(0, Math.min(1, v.chinRaise))));
const cheekLeft = refs["cheek-left"];
if (cheekLeft) cheekLeft.setAttribute("opacity", String(Math.max(0, Math.min(1, v.cheek)) * 0.85));
const cheekRight = refs["cheek-right"];
if (cheekRight) cheekRight.setAttribute("opacity", String(Math.max(0, Math.min(1, v.cheek)) * 0.85));
const pallor = refs.pallor;
if (pallor) pallor.setAttribute("opacity", String(Math.max(0, Math.min(1, v.pallor)) * 0.55));
const tearLeft = refs["tear-left"];
if (tearLeft) {
const pool = Math.max(0, Math.min(1, v.tear / 0.3));
const flow = Math.max(0, (v.tear - 0.3) / 0.7);
tearLeft.setAttribute("opacity", String(pool));
tearLeft.setAttribute("transform", `translate(0 0) scale(${1 + flow * 1.6})`);
}
const tearRight = refs["tear-right"];
if (tearRight) {
const pool = Math.max(0, Math.min(1, v.tear / 0.3));
const flow = Math.max(0, (v.tear - 0.3) / 0.7);
tearRight.setAttribute("opacity", String(pool));
tearRight.setAttribute("transform", `translate(0 0) scale(${1 + flow * 1.6})`);
}
const sweat = refs.sweat;
if (sweat) sweat.setAttribute("opacity", String(Math.max(0, Math.min(1, v.sweat))));
const breath = refs.breath;
if (breath) breath.setAttribute("transform", `scale(${0.85 + v.breath * 0.3})`);
}
function setRef(refs: Refs, name: string) {
return (el: SVGGraphicsElement | null) => {
refs[name] = el;
};
}
export default function DebugFace({ engine, running, nowMs }: DebugFaceProps) {
const refs = useRef<Refs>({});
const rafRef = useRef<number>(0);
useEffect(() => {
if (!running) return;
const clock = nowMs ?? (() => performance.now());
const loop = () => {
const v = engine.evaluate(clock());
renderFrame(refs.current, v);
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [engine, running, nowMs]);
return (
<svg className="df" viewBox="0 0 260 340" role="img" aria-label="아바타 채널 디버그 리그" data-debug-face="true">
<g data-part="body" ref={setRef(refs.current, "body")}>
<rect x="70" y="230" width="120" height="90" rx="26" className="df__torso" data-part="body-torso" />
<g data-part="shoulder" ref={setRef(refs.current, "shoulder")}>
<rect x="52" y="222" width="156" height="26" rx="13" className="df__shoulder" />
</g>
<g data-part="head" ref={setRef(refs.current, "head")}>
<circle cx={CX} cy={HEAD_CY} r="72" className="df__skull" />
<g data-part="face" ref={setRef(refs.current, "face")}>
<path ref={setRef(refs.current, "brow-left")} data-part="brow-left" className="df__brow" />
<path ref={setRef(refs.current, "brow-right")} data-part="brow-right" className="df__brow" />
<g data-part="eye-group-left">
<path ref={setRef(refs.current, "eye-left")} data-part="eye-left" className="df__eye" />
<g ref={setRef(refs.current, "iris-left")}>
<circle cx="96" cy="140" r="7" className="df__iris" />
<circle ref={setRef(refs.current, "pupil-left")} cx="96" cy="140" r="3" className="df__pupil" />
</g>
<ellipse ref={setRef(refs.current, "tear-left")} data-part="tear-left" cx="96" cy="156" rx="3.5" ry="5" className="df__tear" />
</g>
<g data-part="eye-group-right">
<path ref={setRef(refs.current, "eye-right")} data-part="eye-right" className="df__eye" />
<g ref={setRef(refs.current, "iris-right")}>
<circle cx="164" cy="140" r="7" className="df__iris" />
<circle ref={setRef(refs.current, "pupil-right")} cx="164" cy="140" r="3" className="df__pupil" />
</g>
<ellipse ref={setRef(refs.current, "tear-right")} data-part="tear-right" cx="164" cy="156" rx="3.5" ry="5" className="df__tear" />
</g>
<circle ref={setRef(refs.current, "cheek-left")} data-part="cheek-left" cx="86" cy="172" r="14" className="df__cheek" />
<circle ref={setRef(refs.current, "cheek-right")} data-part="cheek-right" cx="174" cy="172" r="14" className="df__cheek" />
<path ref={setRef(refs.current, "sweat")} data-part="sweat" d="M 182 108 q -4 8 0 14 q 4 -6 0 -14 Z" className="df__sweat" />
<path ref={setRef(refs.current, "mouth")} data-part="mouth" className="df__mouth" />
<line ref={setRef(refs.current, "chin")} data-part="chin" x1="118" y1="216" x2="142" y2="216" className="df__chin" />
<ellipse ref={setRef(refs.current, "pallor")} data-part="pallor" cx={CX} cy={HEAD_CY} rx="70" ry="70" className="df__pallor" />
</g>
</g>
</g>
<rect ref={setRef(refs.current, "breath")} data-part="breath" x="120" y="326" width="20" height="8" rx="4" className="df__breath" />
</svg>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,100 @@
/* =====================================================================
LinocutMotif — 결정문 §8.2 상징 모티프층·§8.4 배경 색면·모티프(2단계-B-1b
판정으로 벡터→래스터 스프라이트 전환). 아바타 옆 작은 층으로 둔다.
immersive는 개방도를 반영하지 않는다(budOpenness=null). 정적 SVG + CSS
크로스페이드/keyframes로 구성해 React state를 프레임마다 갱신하지 않는다.
===================================================================== */
import type { BackdropGroup, LinocutRig } from "./linocutRig";
import "./linocut-motif.css";
export type MotifFeedbackMode = "immersive" | "ambient" | "coached";
export interface LinocutMotifProps {
rig: LinocutRig;
group: BackdropGroup;
/** null이면 immersive 고정(0.25). ambient·coached는 실제 개방도 값. */
budOpenness: number | null;
/** 고개 숙임 조건이 모드마다 달라 필요하다. */
feedbackMode: MotifFeedbackMode;
reducedMotion: boolean;
/** px 너비. 기본 96. */
size?: number;
className?: string;
}
const WEATHER_GROUPS: readonly BackdropGroup[] = ["positive", "negative", "defensive", "cognitive", "energy"];
/** 개방도 o(0..1)를 닫힘·반쯤·활짝 가중치로 섞는다. */
function budWeights(o: number): { closed: number; half: number; open: number } {
if (o <= 0.5) return { closed: 1 - 2 * o, half: 2 * o, open: 0 };
return { closed: 0, half: 2 - 2 * o, open: 2 * o - 1 };
}
export default function LinocutMotif({
rig,
group,
budOpenness,
feedbackMode,
reducedMotion,
size = 96,
className,
}: LinocutMotifProps) {
const motif = rig.motif;
const openness = budOpenness ?? 0.25;
const budAttr = budOpenness === null ? "static" : openness.toFixed(2);
/* immersive: negative면 숙인다. ambient·coached: negative이면서 개방도<0.35일 때만
숙인다(우울한 P1은 negative가 잦아, 조건 없이 숙이면 개방도 신호가 가려진다). */
const droop = feedbackMode === "immersive" ? group === "negative" : group === "negative" && openness < 0.35;
const pose: "upright" | "droop" = droop ? "droop" : "upright";
if (!motif) return null;
const weights = budWeights(openness);
const weatherCanvas = motif.weather.canvas;
const budCanvas = motif.bud.canvas;
const totalW = Math.max(weatherCanvas.w, budCanvas.w);
const totalH = weatherCanvas.h + budCanvas.h;
const budX = (totalW - budCanvas.w) / 2;
const weatherX = (totalW - weatherCanvas.w) / 2;
return (
<svg
data-motif="bud-weather"
data-motif-weather={group}
data-motif-bud={budAttr}
data-motif-pose={pose}
data-reduced-motion={reducedMotion}
className={className ? `linocut-motif ${className}` : "linocut-motif"}
width={size}
viewBox={`0 0 ${totalW} ${totalH}`}
role="img"
aria-hidden="true"
>
<g className="linocut-motif__weather">
{WEATHER_GROUPS.map((g) => (
<image
key={g}
href={motif.weather.sprites[g]}
x={weatherX}
y={0}
width={weatherCanvas.w}
height={weatherCanvas.h}
className={`linocut-motif__weather-sprite linocut-motif__weather-${g}`}
opacity={g === group ? 1 : 0}
/>
))}
</g>
<g className="linocut-motif__bud-upright" transform={`translate(${budX} ${weatherCanvas.h})`}>
<image href={motif.bud.closed} width={budCanvas.w} height={budCanvas.h} opacity={weights.closed} />
<image href={motif.bud.half} width={budCanvas.w} height={budCanvas.h} opacity={weights.half} />
<image href={motif.bud.open} width={budCanvas.w} height={budCanvas.h} opacity={weights.open} />
</g>
<g className="linocut-motif__bud-droop" transform={`translate(${budX} ${weatherCanvas.h})`}>
<image href={motif.bud.droop} width={budCanvas.w} height={budCanvas.h} />
</g>
</svg>
);
}

View file

@ -0,0 +1,83 @@
/* =====================================================================
DebugFace 스타일 — 아바타 v3 채널 디버그 리그(아트 품질 아님, 도식용).
===================================================================== */
.df {
width: 100%;
max-width: 320px;
aspect-ratio: 260 / 340;
background: #f4efe6;
border: 1px solid #d8cfbf;
border-radius: 16px;
}
.df__torso {
fill: #7c8a92;
}
.df__shoulder {
fill: #6b7a82;
}
.df__skull {
fill: #f0ddc4;
stroke: #cbb494;
stroke-width: 1;
}
.df__brow {
fill: none;
stroke: #4a3626;
stroke-width: 4;
stroke-linecap: round;
}
.df__eye {
fill: #ffffff;
stroke: #4a3626;
stroke-width: 1.5;
}
.df__iris {
fill: #7a5a3c;
}
.df__pupil {
fill: #241a12;
}
.df__mouth {
fill: #a5473f;
stroke: #6c2c26;
stroke-width: 1;
}
.df__chin {
stroke: #cbb494;
stroke-width: 2;
stroke-linecap: round;
}
.df__cheek {
fill: #e78a8a;
}
.df__sweat {
fill: #bfe0f5;
stroke: #8fbede;
stroke-width: 0.5;
}
.df__tear {
fill: #bfe0f5;
transform-origin: center;
}
.df__pallor {
fill: #d9d9df;
}
.df__breath {
fill: #9fb7a8;
transform-origin: center;
}

View file

@ -0,0 +1,83 @@
/* =====================================================================
linocut-avatar.css — LinocutAvatar 렌더러 스타일. 결정문 §8.4.
배경 전환 1.8s·figure 페이드 0.2s는 reduced motion에서 즉시 전환한다.
===================================================================== */
.linocut-avatar {
display: block;
width: 100%;
height: 100%;
}
.linocut-avatar__backdrop {
transition: fill 1.8s ease;
}
.linocut-avatar__figure {
opacity: 0;
transition: opacity 0.2s ease;
}
.linocut-avatar__figure[data-ready="true"] {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.linocut-avatar__backdrop {
transition: none;
}
.linocut-avatar__figure {
transition: none;
}
}
/* 결·주름 등 순수 스트로크 요소 — 불투명도는 채널값에 따라 JS가 직접 쓴다. */
.linocut-avatar__wrinkle,
.linocut-avatar__crease,
.linocut-avatar__lower-lid,
.linocut-avatar__tear-pool,
.linocut-avatar__iris-radial {
fill: none;
stroke-width: 1.2;
stroke-linecap: round;
}
/* 이마·팔자 주름 — 두께가 변하는 리본(채운 다각형). 불투명도는 JS가 직접 쓴다. */
.linocut-avatar__wrinkle-ribbon {
stroke: none;
}
.linocut-avatar__tear-flow {
stroke-width: 1;
stroke-linejoin: round;
}
/* 눈썹 몸체 위로 삐져나온 털 획 — 정적 불투명도(채널 무관). */
.linocut-avatar__brow-hair {
fill: none;
stroke-width: 1.3;
stroke-linecap: round;
opacity: 0.7;
}
/* 아랫입술 아래 그늘 초승달 해칭(입체감). */
.linocut-avatar__lip-shadow-crescent {
opacity: 0.3;
}
/* 벌어졌을 때(g>0.5) 아랫입술 쪽에 보이는 가는 보조 선. */
.linocut-avatar__mouth-line-secondary {
fill: none;
stroke-width: 1;
stroke-linecap: round;
}
.linocut-avatar__pallor-color {
mix-blend-mode: color;
}
.linocut-avatar__grain {
mix-blend-mode: multiply;
opacity: 0.35;
pointer-events: none;
}

View file

@ -0,0 +1,94 @@
/* =====================================================================
linocut-motif.css — LinocutMotif 스타일(2단계-B-1b 판정으로 래스터
스프라이트 전환). 날씨 1.5s 크로스페이드 + 약한 움직임(해 회전, 비구름·
안개 가로 흔들림), 봉오리는 개방도 3장 크로스페이드 + 자세(곧음/숙임)
순차 페이드. reduced motion(prop 또는 OS 설정)에서는 전부 정지한다.
===================================================================== */
.linocut-motif {
display: block;
overflow: visible;
}
.linocut-motif__weather-sprite {
opacity: 0;
transition: opacity 1.5s ease;
}
/* 봉오리 개방도 3장(닫힘·반쯤·활짝) — 가중치 크로스페이드. */
.linocut-motif__bud-upright image {
transition: opacity 0.3s ease;
}
/* 자세(곧음/숙임) 순차 페이드: 사라지는 쪽 0.6s, 나타나는 쪽 0.4s부터 0.9s까지. */
.linocut-motif__bud-upright,
.linocut-motif__bud-droop {
opacity: 0;
transition: opacity 0.6s ease;
}
.linocut-motif[data-motif-pose="upright"] .linocut-motif__bud-upright,
.linocut-motif[data-motif-pose="droop"] .linocut-motif__bud-droop {
opacity: 1;
transition: opacity 0.5s ease 0.4s;
}
@keyframes linocut-motif-sun-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.linocut-motif__weather-positive {
transform-box: fill-box;
transform-origin: center;
animation: linocut-motif-sun-spin 60s linear infinite;
}
@keyframes linocut-motif-weather-sway {
0%,
100% {
transform: translateX(-2.5px);
}
50% {
transform: translateX(2.5px);
}
}
.linocut-motif__weather-negative {
animation: linocut-motif-weather-sway 7s ease-in-out infinite;
}
.linocut-motif__weather-defensive {
animation: linocut-motif-weather-sway 8s ease-in-out infinite;
}
/* energy(초승달·별)·cognitive(옅은 안개결)는 정지. */
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-sprite,
.linocut-motif[data-reduced-motion="true"] .linocut-motif__bud-upright,
.linocut-motif[data-reduced-motion="true"] .linocut-motif__bud-droop {
transition: none;
}
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-positive,
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-negative,
.linocut-motif[data-reduced-motion="true"] .linocut-motif__weather-defensive {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.linocut-motif__weather-sprite,
.linocut-motif__bud-upright,
.linocut-motif__bud-droop {
transition: none;
}
.linocut-motif__weather-positive,
.linocut-motif__weather-negative,
.linocut-motif__weather-defensive {
animation: none;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,138 @@
/* =====================================================================
리노컷 리그 v3 데이터 계약 — 결정문 §8.2. 오케스트레이터 작성(워커 수정 금지).
페르소나 리그 데이터(`rigs/*Rig.ts`)는 자산 파이프라인 스크립트가 생성하고,
렌더러는 이 타입만 읽는다.
좌표는 모두 캔버스 픽셀(원점 좌상단, y 아래로 증가)이다.
좌우 표기는 화면 기준이다: Left = 작은 x. 엔진 채널의 L/R(eyeOpenL 등)도
화면 기준이며 DebugFace와 같다.
===================================================================== */
export type RigPoint = readonly [number, number];
/** [x, y, w, h] — 캔버스 좌표 사각형. */
export type RigRect = readonly [number, number, number, number];
export interface RigLayer {
/** public 기준 URL(예: `/avatar/v3/p1/head.webp`). */
href: string;
/** 이미지가 놓이는 캔버스 사각형. 파일 해상도와 무관하게 이 크기로 그린다. */
x: number;
y: number;
w: number;
h: number;
}
export interface RigEye {
inner: RigPoint;
outer: RigPoint;
upperLidTop: RigPoint;
lowerLidBottom: RigPoint;
iris: { center: RigPoint; radius: number };
}
/** 눈썹 잉크 띠의 **중심선** 위 세 점(결정문 §8.4). 띠의 윗가장자리·아래 모서리를 찍으면
수평에 가까운 원화 눈썹이 사선으로 그려져 중립이 화난 얼굴이 된다(2단계-B 3차 판정). */
export interface RigBrow {
inner: RigPoint;
peak: RigPoint;
outer: RigPoint;
}
export interface RigLandmarks {
eyeLeft: RigEye;
eyeRight: RigEye;
browLeft: RigBrow;
browRight: RigBrow;
noseTip: RigPoint;
mouthCornerLeft: RigPoint;
mouthCornerRight: RigPoint;
/** 윗입술 윗선 중앙. */
upperLipTop: RigPoint;
/** 아랫입술 아랫선 중앙. */
lowerLipBottom: RigPoint;
/** 두 입술이 만나는 선의 중앙. */
mouthCenter: RigPoint;
chinTip: RigPoint;
}
export type RigCrop = "portrait" | "bust" | "face";
/** 배경 색면·날씨 모티프 그룹 = 겉표정 그룹(`persona.ts` AVATAR_EXPRESSION_LIBRARY group). */
export type BackdropGroup = "positive" | "negative" | "defensive" | "cognitive" | "energy";
export interface RigPalette {
ink: string;
sclera: string;
iris: string;
irisRing: string;
lipUpper: string;
lipLower: string;
mouthLine: string;
mouthInner: string;
teeth: string;
blush: string;
tear: string;
pallor: string;
paper: string;
motifPetal: string;
motifLeaf: string;
}
/** 상징 모티프 스프라이트(결정문 §8.4). 좌표는 각 스프라이트 캔버스 픽셀이다. */
export interface RigMotif {
bud: {
/** 봉오리 스프라이트 공통 캔버스. 4장 모두 줄기 밑동이 base에 맞춰져 있다. */
canvas: { w: number; h: number };
base: RigPoint;
closed: string;
half: string;
open: string;
droop: string;
};
weather: {
canvas: { w: number; h: number };
sprites: Record<BackdropGroup, string>;
};
}
export interface LinocutRig {
schemaVersion: "vignette.avatar.v3.rig.v1";
persona: string;
canvas: { w: number; h: number };
layers: {
body: RigLayer;
head: RigLayer;
hairFront: RigLayer;
/** 눈두덩·다크서클·입가 음영과 점. 표정 부위 구멍은 피부로 메워져 있다. */
faceDetail?: RigLayer;
};
/** 종이결 타일(곱하기 합성). size는 캔버스 단위 타일 한 변. */
grain?: { href: string; size: number };
/** 원화 입술 결 스프라이트(결정문 §8.4 입). 입 선·입꼬리 잉크를 입술 결로 메우고 가장자리를
24px 연장했다. 렌더러가 현재 입술 면으로 clip하고 입술 기준점 이동·입 폭 비율만큼 옮기고 늘린다.
없으면 입술을 palette 단색으로 채운다. */
lipTexture?: {
upper: RigLayer;
lower: RigLayer;
/** 아랫입술 아래 그늘(원화 픽셀). 입 모양을 따라 가로로 줄지 않고 턱 변위만 따른다. */
shadow?: RigLayer;
};
/** 하관 띠 변형용으로 잘라 낸 head·faceDetail 조각(결정문 §8.4). 원본 레이어와 픽셀이 같다. */
jaw?: { head: RigLayer; detail: RigLayer };
pivots: {
/** 고개 회전 중심(목). */
neck: RigPoint;
/** 몸 기울임·호흡 중심(하단 중앙). */
body: RigPoint;
/** 얼굴 부위 시차·압축 중심. */
face: RigPoint;
};
crops: Record<RigCrop, RigRect>;
/** 얼굴 윤곽 다각형(표정 부위 clip). */
faceOval: readonly RigPoint[];
landmarks: RigLandmarks;
palette: RigPalette;
backdrop: Record<BackdropGroup, string>;
/** 없으면 모티프를 그리지 않는다. */
motif?: RigMotif;
}

View file

@ -0,0 +1,104 @@
/* 생성 파일 — docs/avatar-art/p1-linocut/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */
import type { LinocutRig } from "../linocutRig";
export const P1_LINOCUT_RIG: LinocutRig = {
schemaVersion: "vignette.avatar.v3.rig.v1",
persona: "P1",
canvas: { w: 1005, h: 1566 },
layers: {
body: { href: "/avatar/v3/p1/body.webp", x: 0, y: 807, w: 1005, h: 759 },
head: { href: "/avatar/v3/p1/head.webp", x: 111, y: 72, w: 849, h: 1049 },
hairFront: { href: "/avatar/v3/p1/hair-front.webp", x: 260, y: 391, w: 491, h: 516 },
faceDetail: { href: "/avatar/v3/p1/face-detail.webp", x: 284, y: 444, w: 437, h: 478 },
},
grain: { href: "/avatar/v3/p1/paper-grain.webp", size: 256 },
lipTexture: {
upper: { href: "/avatar/v3/p1/lip-upper.webp", x: 404, y: 758, w: 182, h: 54 },
lower: { href: "/avatar/v3/p1/lip-lower.webp", x: 404, y: 798, w: 182, h: 49 },
shadow: { href: "/avatar/v3/p1/lip-shadow.webp", x: 414, y: 802, w: 162, h: 53 },
},
jaw: {
head: { href: "/avatar/v3/p1/jaw-head.webp", x: 260, y: 653, w: 500, h: 307 },
detail: { href: "/avatar/v3/p1/jaw-detail.webp", x: 260, y: 653, w: 500, h: 307 },
},
motif: {
bud: {
canvas: { w: 280, h: 420 },
base: [134.2, 399],
closed: "/avatar/v3/p1/motif/bud-closed.webp",
half: "/avatar/v3/p1/motif/bud-half.webp",
open: "/avatar/v3/p1/motif/bud-open.webp",
droop: "/avatar/v3/p1/motif/bud-droop.webp",
},
weather: {
canvas: { w: 320, h: 200 },
sprites: {
positive: "/avatar/v3/p1/motif/weather-positive.webp",
negative: "/avatar/v3/p1/motif/weather-negative.webp",
defensive: "/avatar/v3/p1/motif/weather-defensive.webp",
cognitive: "/avatar/v3/p1/motif/weather-cognitive.webp",
energy: "/avatar/v3/p1/motif/weather-energy.webp",
},
},
},
pivots: {
neck: [500, 990],
body: [502, 1566],
face: [490, 660],
},
crops: {
portrait: [0, 0, 1005, 1566],
bust: [0, 40, 1005, 1005],
face: [204.7, 360.6, 601.4, 601.4],
},
faceOval: [[481, 392.8], [551.8, 393.6], [612.4, 402.3], [669.7, 421.4], [707.5, 450.3], [731.2, 487.1], [743.8, 526.1], [748.5, 573.7], [745.6, 619.2], [739.1, 666.9], [727.3, 716.7], [709.7, 769.5], [687, 813.6], [661.3, 846.5], [629.6, 874.9], [603.3, 894.4], [574.9, 912.1], [542.1, 926], [500.6, 929.7], [461.5, 924.7], [432.5, 910.1], [408, 891.9], [384.7, 872.6], [356.2, 844.9], [333.7, 812.9], [312.5, 770.3], [294.2, 718.1], [281, 668.9], [273, 622.3], [265.1, 577.6], [262.2, 530.6], [267.6, 491.4], [282.1, 455.1], [310, 426.1], [357.1, 405.8], [412.2, 395.7]],
landmarks: {
eyeLeft: {
inner: [430.9, 592.2],
outer: [339.1, 578.8],
upperLidTop: [376.3, 564.3],
lowerLidBottom: [381.1, 598.2],
iris: { center: [387.3, 579.1], radius: 21.4 },
},
eyeRight: {
inner: [552.8, 590.9],
outer: [648, 575.5],
upperLidTop: [607.1, 562.1],
lowerLidBottom: [603.9, 595],
iris: { center: [601.9, 576.2], radius: 21.3 },
},
browLeft: { inner: [450.6, 525.1], peak: [360.1, 510.4], outer: [313.4, 513.3] },
browRight: { inner: [518.2, 521.7], peak: [626.8, 508.2], outer: [682.8, 510.7] },
noseTip: [486.6, 713],
mouthCornerLeft: [422.1, 804.7],
mouthCornerRight: [567.5, 805.8],
upperLipTop: [489.8, 773.4],
lowerLipBottom: [493, 831.5],
mouthCenter: [491.6, 797.5],
chinTip: [500.6, 919.5],
},
palette: {
ink: "#1E1F1F",
sclera: "#D8CEBD",
iris: "#4F3B2C",
irisRing: "#1E1F1F",
lipUpper: "#97684B",
lipLower: "#AB7857",
mouthLine: "#292421",
mouthInner: "#3B201B",
teeth: "#E9E0CF",
blush: "#C0624A",
tear: "#EEE5D3",
pallor: "#9AA3A6",
paper: "#EEE5D3",
motifPetal: "#D0A362",
motifLeaf: "#53626C",
},
backdrop: {
cognitive: "#ECE3D1",
positive: "#F1DEC2",
negative: "#DCE0E2",
defensive: "#E6DAD3",
energy: "#E2E0D0",
},
};

View file

@ -0,0 +1,723 @@
/* =====================================================================
AvatarLab — 아바타 v3 표현 엔진 dev Lab (무인증). /dev/avatar-lab.
실 서비스 라우트 아님 — 채널·표정·반응 클립·지문 파싱·엔진 상태를
눈으로 확인하는 도구다(결정문 §9 1단계 완료 기준).
===================================================================== */
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import DebugFace from "../components/avatar/v3/DebugFace";
import LinocutAvatar from "../components/avatar/v3/LinocutAvatar";
import LinocutMotif from "../components/avatar/v3/LinocutMotif";
import { P1_LINOCUT_RIG } from "../components/avatar/v3/rigs/p1Rig";
import type { LinocutRig } from "../components/avatar/v3/linocutRig";
import { CHANNEL_IDS } from "../components/avatar/engine/channels";
import { createAvatarEngine, type AvatarEngine, type DebugSnapshot } from "../components/avatar/engine/engine";
import { REACTION_CLIPS, REACTION_CLIP_IDS, type ReactionClipId } from "../components/avatar/engine/clipCatalog";
import { demeanorFor } from "../components/avatar/engine/demeanorDefaults";
import { buildPerformance, type Performance, type PerformanceCue } from "../components/avatar/engine/performance";
import {
buildSpeechTimeline,
currentViseme,
sampleSpeech,
type PhraseKind,
type SpeechShape,
type SpeechTimeline,
type VisemeId,
} from "../components/avatar/engine/lipsync";
import { buildCoSpeechPlan, isStressPulseActive, sampleCoSpeech, type CoSpeechPlan } from "../components/avatar/engine/coSpeech";
import { computeEnvelope, type SpeechEnvelope } from "../components/avatar/engine/speechEnvelope";
import { AVATAR_EXPRESSION_LIBRARY, type AvatarExpression, type AvatarState } from "../components/avatar/persona";
import "./avatar-lab.css";
const PERSONA_OPTIONS: Array<{ code: string; label: string }> = [
{ code: "P1", label: "P1 서연" },
{ code: "P2", label: "P2 민재" },
{ code: "P3", label: "P3 지우" },
{ code: "P4", label: "P4 하늘" },
{ code: "P5", label: "P5 도윤" },
{ code: "P6", label: "P6 하린" },
{ code: "P7", label: "P7 도현" },
];
/** 리노컷 리그가 있는 페르소나만. 없는 페르소나는 debug 렌더러를 강제한다. */
const LINOCUT_RIGS: Partial<Record<string, LinocutRig>> = { P1: P1_LINOCUT_RIG };
type RendererMode = "linocut" | "debug";
type FeedbackMode = "immersive" | "ambient" | "coached";
const STATE_OPTIONS: Array<{ id: AvatarState; label: string }> = [
{ id: "idle", label: "대기" },
{ id: "listening", label: "경청" },
{ id: "thinking", label: "생각" },
{ id: "speaking", label: "발화" },
];
interface Clock {
now(): number;
pause(): void;
resume(): void;
isPaused(): boolean;
}
function createClock(): Clock {
let offset = 0;
let paused = false;
let frozen = 0;
return {
now(): number {
return paused ? frozen : performance.now() - offset;
},
pause(): void {
if (paused) return;
frozen = performance.now() - offset;
paused = true;
},
resume(): void {
if (!paused) return;
offset = performance.now() - frozen;
paused = false;
},
isPaused(): boolean {
return paused;
},
};
}
/** 표시용: localMs 시점까지 시작한 가장 최근 구의 종류. 구 사이 휴지 중에는 그 직전 구를 보인다. */
function currentPhraseKind(timeline: SpeechTimeline, localMs: number): PhraseKind | null {
let kind: PhraseKind | null = null;
for (const ph of timeline.phrases) {
if (ph.startMs > localMs) break;
kind = ph.kind;
}
return kind;
}
function seedFromQuery(): number {
const raw = new URLSearchParams(window.location.search).get("seed");
const parsed = raw === null ? NaN : Number(raw);
return Number.isFinite(parsed) ? parsed : 1;
}
export default function AvatarLab() {
const seed = useMemo(seedFromQuery, []);
const clock = useRef<Clock>(createClock()).current;
const [personaCode, setPersonaCode] = useState("P1");
const [avatarState, setAvatarState] = useState<AvatarState>("idle");
const [openness, setOpenness] = useState(0.4);
const [reducedMotion, setReducedMotion] = useState(false);
const [paused, setPaused] = useState(false);
const [selectedExpression, setSelectedExpression] = useState<AvatarExpression>("neutral");
const [intensity, setIntensity] = useState(0.7);
const [mirrorClip, setMirrorClip] = useState(false);
const rig = LINOCUT_RIGS[personaCode];
const [rendererMode, setRendererMode] = useState<RendererMode>(rig ? "linocut" : "debug");
const [feedbackMode, setFeedbackMode] = useState<FeedbackMode>("coached");
const [linocutLoadState, setLinocutLoadState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
if (!rig) setRendererMode("debug");
}, [rig]);
const backdropGroup = useMemo(
() => AVATAR_EXPRESSION_LIBRARY.find((e) => e.id === selectedExpression)?.group ?? "cognitive",
[selectedExpression],
);
const [speechText, setSpeechText] = useState("(한숨) 그냥요. 별로 할 말 없어요.");
const [parsedCues, setParsedCues] = useState<PerformanceCue[]>([]);
const [unmatched, setUnmatched] = useState<string[]>([]);
const [snapshot, setSnapshot] = useState<DebugSnapshot | null>(null);
const speechRafRef = useRef<number>(0);
const meterRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
/* 발화층(립싱크) — 원시 SpeechShape·비짐 표시용. 채널 미터와 달리 매 프레임 갱신하지
않고(React state 금지, 결정문 §3) 아래 100ms interval에서 ref로 직접 쓴다. */
const speechDisplayRef = useRef<{ viseme: VisemeId; shape: SpeechShape }>({
viseme: "X",
shape: { open: 0, wide: 0, round: 0, press: 0 },
});
const speechMeterRefs = useRef<{
viseme: HTMLElement | null;
open: HTMLElement | null;
wide: HTMLElement | null;
round: HTMLElement | null;
press: HTMLElement | null;
}>({ viseme: null, open: null, wide: null, round: null, press: null });
/* 발화 동반층(§5.5) 표시용 — 현재 구 종류·강세 박 여부. speechDisplayRef와 같은 이유로
React state가 아니라 ref로 갱신하고 100ms interval에서 DOM에 직접 쓴다. */
const coSpeechDisplayRef = useRef<{ phraseKind: PhraseKind | null; stressed: boolean }>({ phraseKind: null, stressed: false });
const coSpeechMeterRefs = useRef<{ phraseKind: HTMLElement | null; stressed: HTMLElement | null }>({
phraseKind: null,
stressed: null,
});
const audioCtxRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
const audioFileInputRef = useRef<HTMLInputElement | null>(null);
const [audioFileName, setAudioFileName] = useState<string | null>(null);
const engine: AvatarEngine = useMemo(
() => createAvatarEngine({ demeanor: demeanorFor(personaCode), seed, reducedMotion }),
[personaCode, seed, reducedMotion],
);
useEffect(() => {
engine.setState(avatarState, clock.now());
engine.setOpenness(openness);
engine.setSurface(selectedExpression, intensity);
}, [engine]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
engine.setState(avatarState, clock.now());
}, [engine, avatarState]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
engine.setOpenness(openness);
}, [engine, openness]);
useEffect(() => {
engine.setSurface(selectedExpression, intensity);
}, [engine, selectedExpression, intensity]);
useEffect(() => {
setSnapshot(engine.debugSnapshot());
const interval = window.setInterval(() => {
const v = engine.evaluate(clock.now());
for (const id of CHANNEL_IDS) {
const cell = meterRefs.current[id];
if (cell) cell.textContent = v[id].toFixed(2);
}
setSnapshot(engine.debugSnapshot());
const sd = speechDisplayRef.current;
const sm = speechMeterRefs.current;
if (sm.viseme) sm.viseme.textContent = sd.viseme;
if (sm.open) sm.open.textContent = sd.shape.open.toFixed(2);
if (sm.wide) sm.wide.textContent = sd.shape.wide.toFixed(2);
if (sm.round) sm.round.textContent = sd.shape.round.toFixed(2);
if (sm.press) sm.press.textContent = sd.shape.press.toFixed(2);
const cd = coSpeechDisplayRef.current;
const cm = coSpeechMeterRefs.current;
if (cm.phraseKind) cm.phraseKind.textContent = cd.phraseKind ?? "-";
if (cm.stressed) cm.stressed.textContent = cd.stressed ? "예" : "아니오";
}, 100);
return () => window.clearInterval(interval);
}, [engine]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
return () => {
cancelAnimationFrame(speechRafRef.current);
try {
audioSourceRef.current?.stop();
} catch {
/* 이미 정지된 소스면 무시한다. */
}
};
}, []);
function togglePause(): void {
if (clock.isPaused()) {
clock.resume();
setPaused(false);
} else {
clock.pause();
setPaused(true);
}
}
function handleClipClick(id: ReactionClipId): void {
engine.triggerClip(id, clock.now(), { mirror: mirrorClip });
}
const ZERO_SPEECH_SHAPE: SpeechShape = { open: 0, wide: 0, round: 0, press: 0 };
function endSpeech(nowMs: number): void {
engine.setSpeechShape(null);
engine.setSpeechMotion(null);
engine.setState("listening", nowMs);
setAvatarState("listening");
speechDisplayRef.current = { viseme: "X", shape: ZERO_SPEECH_SHAPE };
coSpeechDisplayRef.current = { phraseKind: null, stressed: false };
}
/** timeline을 nowFn() 시계로 매 프레임 표본해 engine.setSpeechShape·setSpeechMotion에 흘려보낸다.
coSpeechPlan이 있으면 발화 동반층(§5.5)도 같은 프레임에 표본한다. */
function runSpeechTimeline(
timeline: SpeechTimeline,
speechStartMs: number,
nowFn: () => number,
articulation: number,
coSpeechPlan: CoSpeechPlan | null,
envelope?: SpeechEnvelope,
): void {
cancelAnimationFrame(speechRafRef.current);
const endMs = speechStartMs + timeline.totalDurationMs;
let prevLocalMs = -Infinity;
const step = () => {
const t = nowFn();
if (t < speechStartMs) {
speechRafRef.current = requestAnimationFrame(step);
return;
}
if (t >= endMs) {
endSpeech(clock.now());
return;
}
const localMs = t - speechStartMs;
const shape = sampleSpeech(timeline, localMs, articulation);
engine.setSpeechShape(shape);
speechDisplayRef.current = { viseme: currentViseme(timeline, localMs), shape };
if (coSpeechPlan) {
const sample = sampleCoSpeech(coSpeechPlan, localMs, prevLocalMs, envelope);
engine.setSpeechMotion(sample.delta);
if (sample.blinkNow) engine.requestSpeechBlink(clock.now());
coSpeechDisplayRef.current = {
phraseKind: currentPhraseKind(timeline, localMs),
stressed: isStressPulseActive(coSpeechPlan, localMs),
};
}
prevLocalMs = localMs;
speechRafRef.current = requestAnimationFrame(step);
};
speechRafRef.current = requestAnimationFrame(step);
}
function playSpeech(): void {
const nowMs = clock.now();
const { performance: perf, unmatched: um } = buildPerformance({
text: speechText,
expression: selectedExpression,
intensity,
openness,
seed,
});
const speech = demeanorFor(personaCode).speech;
const timeline = buildSpeechTimeline({ text: speechText, syllablesPerSec: speech.syllablesPerSec });
const coSpeechPlan = buildCoSpeechPlan(timeline, speech, seed);
const speechStartMs = nowMs + 300;
setAvatarState("speaking");
engine.setState("speaking", nowMs);
engine.playPerformance(perf, { speechStartMs, speechDurationMs: timeline.totalDurationMs }, nowMs);
setParsedCues(perf.cues);
setUnmatched(um);
runSpeechTimeline(timeline, speechStartMs, clock.now, speech.articulation, coSpeechPlan);
}
function handleAudioFileChange(e: ChangeEvent<HTMLInputElement>): void {
setAudioFileName(e.target.files?.[0]?.name ?? null);
}
/** "오디오 파일로 말하기" — 로컬 오디오를 디코드해 포락선을 만들고, 오디오 시계로 표본한다. */
async function playSpeechWithAudioFile(): Promise<void> {
const file = audioFileInputRef.current?.files?.[0];
if (!file) return;
const AudioContextCtor =
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextCtor) return;
const ctx = audioCtxRef.current ?? new AudioContextCtor();
audioCtxRef.current = ctx;
if (ctx.state === "suspended") await ctx.resume();
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0));
const channels: Float32Array[] = [];
for (let c = 0; c < audioBuffer.numberOfChannels; c++) channels.push(audioBuffer.getChannelData(c));
const envelope = computeEnvelope(channels, audioBuffer.sampleRate);
const nowMs = clock.now();
const { performance: perf, unmatched: um } = buildPerformance({
text: speechText,
expression: selectedExpression,
intensity,
openness,
seed,
});
const speech = demeanorFor(personaCode).speech;
const timeline = buildSpeechTimeline({ text: speechText, syllablesPerSec: speech.syllablesPerSec, envelope });
const coSpeechPlan = buildCoSpeechPlan(timeline, speech, seed);
try {
audioSourceRef.current?.stop();
} catch {
/* 이미 정지된 소스면 무시한다. */
}
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
audioSourceRef.current = source;
const speechStartMs = nowMs + 300;
setAvatarState("speaking");
engine.setState("speaking", nowMs);
engine.playPerformance(perf, { speechStartMs, speechDurationMs: timeline.totalDurationMs }, nowMs);
setParsedCues(perf.cues);
setUnmatched(um);
const startAtCtx = ctx.currentTime + 0.3;
source.start(startAtCtx);
/* 오디오 시계 기준(초 → ms). Lab의 clock(performance.now() 기반)과는 별개 시계다 —
재생 시작을 같은 300ms로 맞췄지만 독립 시계라 아주 긴 발화에서는 드리프트가 있을 수 있다. */
const nowFn = () => (ctx.currentTime - startAtCtx) * 1000 + speechStartMs;
runSpeechTimeline(timeline, speechStartMs, nowFn, speech.articulation, coSpeechPlan, envelope);
}
function playLeakTest(): void {
const nowMs = clock.now();
const perf: Performance = {
surface: { expression: "neutral", intensity: 0.3 },
leak: { expression: "sad", intensity: 0.8, atMs: 500, durationMs: 350 },
cues: [],
};
setAvatarState("listening");
engine.setState("listening", nowMs);
engine.playPerformance(perf, { speechStartMs: nowMs, speechDurationMs: 1200 }, nowMs);
setParsedCues([]);
setUnmatched([]);
}
return (
<div className="al" data-avatar-lab="true">
<header className="al__head">
<h1>아바타 v3 표현 엔진 Lab</h1>
<p>채널·표정·반응 클립·지문 파서를 렌더러와 분리해 확인하는 개발 도구다(실 서비스 아님).</p>
</header>
<section className="al__controls">
<label>
페르소나
<select value={personaCode} onChange={(e) => setPersonaCode(e.target.value)}>
{PERSONA_OPTIONS.map((p) => (
<option key={p.code} value={p.code}>
{p.label}
</option>
))}
</select>
</label>
<label>
상태
<select value={avatarState} onChange={(e) => setAvatarState(e.target.value as AvatarState)}>
{STATE_OPTIONS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</label>
<label>
개방도 {openness.toFixed(2)}
<input
type="range"
min={0}
max={1}
step={0.01}
value={openness}
onChange={(e) => setOpenness(Number(e.target.value))}
data-testid="openness-slider"
/>
</label>
<label className="al__toggle">
<input
type="checkbox"
checked={reducedMotion}
onChange={(e) => setReducedMotion(e.target.checked)}
data-testid="reduced-motion-toggle"
/>
Reduced motion
</label>
<fieldset className="al__renderer">
<legend>렌더러</legend>
<label className="al__toggle">
<input
type="radio"
name="renderer"
checked={rendererMode === "linocut"}
disabled={!rig}
onChange={() => setRendererMode("linocut")}
data-testid="renderer-linocut"
/>
linocut
</label>
<label className="al__toggle">
<input
type="radio"
name="renderer"
checked={rendererMode === "debug"}
onChange={() => setRendererMode("debug")}
data-testid="renderer-debug"
/>
debug
</label>
</fieldset>
<label>
피드백 모드
<select
value={feedbackMode}
onChange={(e) => setFeedbackMode(e.target.value as FeedbackMode)}
data-testid="feedback-mode"
>
<option value="immersive">immersive</option>
<option value="ambient">ambient</option>
<option value="coached">coached</option>
</select>
</label>
<button type="button" onClick={togglePause} data-testid="pause-toggle">
{paused ? "재개" : "일시정지"}
</button>
</section>
<section className="al__stage">
{rendererMode === "linocut" && rig ? (
<>
<div className="al__linocut-main">
<LinocutAvatar
rig={rig}
engine={engine}
running
nowMs={clock.now}
crop="bust"
backdropExpression={selectedExpression}
onLoadStateChange={setLinocutLoadState}
/>
</div>
<LinocutMotif
rig={rig}
group={backdropGroup}
budOpenness={feedbackMode === "immersive" ? null : openness}
feedbackMode={feedbackMode}
reducedMotion={reducedMotion}
size={96}
/>
</>
) : (
<DebugFace engine={engine} running nowMs={clock.now} />
)}
</section>
{rendererMode === "linocut" && rig ? (
<>
<p data-testid="linocut-load-state">로드 상태: {linocutLoadState}</p>
<section className="al__preview-row">
<div className="al__preview-cell al__preview-cell--bust-circle">
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="bust" backdropExpression={selectedExpression} />
</div>
<div className="al__preview-cell al__preview-cell--face-96">
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="face" backdropExpression={selectedExpression} />
</div>
<div className="al__preview-cell al__preview-cell--face-48">
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="face" backdropExpression={selectedExpression} />
</div>
<div className="al__preview-cell al__preview-cell--portrait">
<LinocutAvatar rig={rig} engine={engine} running nowMs={clock.now} crop="portrait" backdropExpression={selectedExpression} />
</div>
</section>
</>
) : null}
<section className="al__panel">
<h2>표정 (28개)</h2>
<label className="al__intensity">
강도 {intensity.toFixed(2)}
<input
type="range"
min={0}
max={1}
step={0.01}
value={intensity}
onChange={(e) => setIntensity(Number(e.target.value))}
/>
</label>
<div className="al__grid">
{AVATAR_EXPRESSION_LIBRARY.map((expr) => (
<button
key={expr.id}
type="button"
className={expr.id === selectedExpression ? "al__chip al__chip--active" : "al__chip"}
data-testid={`expression-${expr.id}`}
onClick={() => setSelectedExpression(expr.id)}
>
{expr.label}
</button>
))}
</div>
</section>
<section className="al__panel">
<h2>반응 클립 (26개)</h2>
<label className="al__toggle">
<input type="checkbox" checked={mirrorClip} onChange={(e) => setMirrorClip(e.target.checked)} />
좌우 반전
</label>
<div className="al__grid">
{REACTION_CLIP_IDS.map((id) => (
<button
key={id}
type="button"
className="al__chip"
data-testid={`clip-${id}`}
onClick={() => handleClipClick(id)}
>
{REACTION_CLIPS[id].label}
</button>
))}
</div>
</section>
<section className="al__panel">
<h2>내담자 발화 · 공연 재생</h2>
<textarea
className="al__speech-input"
value={speechText}
onChange={(e) => setSpeechText(e.target.value)}
rows={3}
data-testid="speech-text"
/>
<div className="al__actions">
<button type="button" onClick={playSpeech} data-testid="play-speech">
공연 재생
</button>
<button type="button" onClick={playLeakTest} data-testid="play-leak-test">
누설 테스트
</button>
</div>
<div className="al__actions al__audio-file">
<input
ref={audioFileInputRef}
type="file"
accept="audio/*"
onChange={handleAudioFileChange}
data-testid="speech-audio-file"
/>
<button
type="button"
onClick={() => void playSpeechWithAudioFile()}
disabled={!audioFileName}
data-testid="play-speech-audio"
>
오디오 파일로 말하기
</button>
{audioFileName ? <span className="al__audio-file-name">{audioFileName}</span> : null}
</div>
<table className="al__meter al__speech-shape">
<thead>
<tr>
<th>비짐</th>
<th>open</th>
<th>wide</th>
<th>round</th>
<th>press</th>
</tr>
</thead>
<tbody>
<tr>
<td ref={(el) => { speechMeterRefs.current.viseme = el; }} data-testid="speech-shape-viseme">
X
</td>
<td ref={(el) => { speechMeterRefs.current.open = el; }} data-testid="speech-shape-open">
0.00
</td>
<td ref={(el) => { speechMeterRefs.current.wide = el; }} data-testid="speech-shape-wide">
0.00
</td>
<td ref={(el) => { speechMeterRefs.current.round = el; }} data-testid="speech-shape-round">
0.00
</td>
<td ref={(el) => { speechMeterRefs.current.press = el; }} data-testid="speech-shape-press">
0.00
</td>
</tr>
</tbody>
</table>
<p className="al__co-speech-status">
구: <span ref={(el) => { coSpeechMeterRefs.current.phraseKind = el; }} data-testid="co-speech-phrase-kind">-</span>
{" · "}
강세 박: <span ref={(el) => { coSpeechMeterRefs.current.stressed = el; }} data-testid="co-speech-stressed">아니오</span>
</p>
<div className="al__parse-result">
<h3>파싱된 cue</h3>
<ul data-testid="parsed-cues">
{parsedCues.map((cue, i) => (
<li key={i} data-clip={cue.clip} data-anchor={cue.anchor} data-source={cue.source}>
{cue.clip} / {cue.anchor} / at={cue.at.toFixed(2)} / {cue.source}
</li>
))}
</ul>
<h3>미대응 지문</h3>
<ul data-testid="unmatched-directions">
{unmatched.map((text, i) => (
<li key={i}>{text}</li>
))}
</ul>
</div>
</section>
<section className="al__panel">
<h2>채널 미터 ({CHANNEL_IDS.length}개)</h2>
<table className="al__meter">
<thead>
<tr>
<th>채널</th>
<th>값</th>
</tr>
</thead>
<tbody>
{CHANNEL_IDS.map((id) => (
<tr key={id} data-channel={id}>
<td>{id}</td>
<td
data-value
ref={(el) => {
meterRefs.current[id] = el;
}}
>
0.00
</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="al__panel">
<h2>엔진 스냅샷</h2>
<h3>활성 클립</h3>
<ul data-testid="active-clips">
{(snapshot?.activeClips ?? []).map((c, i) => (
<li key={i} data-clip={c.id}>
{c.id} · started={c.startedMs.toFixed(0)} · weight={c.weight.toFixed(2)} · mirror={String(c.mirror)}
</li>
))}
</ul>
<h3>예약된 cue</h3>
<ul data-testid="scheduled-cues">
{(snapshot?.scheduled ?? []).map((c, i) => (
<li key={i} data-clip={c.clip}>
{c.clip} · at={c.atMs.toFixed(0)} · {c.source}
</li>
))}
</ul>
<p data-testid="leak-active">누설 활성: {snapshot?.leakActive ? "예" : "아니오"}</p>
</section>
</div>
);
}

View file

@ -0,0 +1,197 @@
.al {
padding: 24px;
max-width: 1100px;
margin: 0 auto;
color: #2a231b;
font-size: 14px;
}
.al__head h1 {
margin: 0 0 4px;
font-size: 22px;
}
.al__head p {
margin: 0 0 20px;
color: #6b5f50;
}
.al__controls {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
padding: 12px 16px;
border: 1px solid #ddd2bd;
border-radius: 12px;
margin-bottom: 20px;
}
.al__controls label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #6b5f50;
}
.al__toggle {
flex-direction: row !important;
align-items: center;
gap: 6px !important;
}
.al__stage {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 24px;
margin-bottom: 24px;
}
.al__renderer {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
border: 1px solid #ddd2bd;
border-radius: 8px;
padding: 4px 10px;
font-size: 12px;
color: #6b5f50;
}
.al__linocut-main {
width: 420px;
height: 420px;
flex: none;
}
.al__preview-row {
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: flex-end;
margin-bottom: 24px;
}
.al__preview-cell {
flex: none;
background: #f3ead9;
}
.al__preview-cell--bust-circle {
width: 200px;
height: 200px;
border-radius: 50%;
overflow: hidden;
}
.al__preview-cell--face-96 {
width: 96px;
height: 96px;
border-radius: 50%;
overflow: hidden;
}
.al__preview-cell--face-48 {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
}
.al__preview-cell--portrait {
width: 240px;
}
.al__panel {
margin-bottom: 28px;
padding: 16px;
border: 1px solid #ddd2bd;
border-radius: 12px;
}
.al__panel h2 {
margin: 0 0 10px;
font-size: 16px;
}
.al__intensity {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
font-size: 12px;
color: #6b5f50;
}
.al__grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.al__chip {
padding: 6px 12px;
border: 1px solid #ccbfa4;
border-radius: 999px;
background: #fbf7ef;
cursor: pointer;
font-size: 12px;
}
.al__chip--active {
background: #6b5e7d;
color: #fff;
border-color: #6b5e7d;
}
.al__speech-input {
width: 100%;
box-sizing: border-box;
padding: 8px;
border: 1px solid #ccbfa4;
border-radius: 8px;
font-family: inherit;
font-size: 13px;
}
.al__actions {
display: flex;
gap: 10px;
margin: 10px 0;
}
.al__parse-result ul {
margin: 4px 0 12px;
padding-left: 18px;
font-size: 12px;
}
.al__meter {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.al__meter th,
.al__meter td {
border-bottom: 1px solid #eee2cd;
padding: 3px 6px;
text-align: left;
}
.al__audio-file {
align-items: center;
}
.al__audio-file-name {
font-size: 12px;
color: #6b5f50;
}
.al__speech-shape {
margin-bottom: 12px;
max-width: 360px;
}