아바타 v3 세션 연결 — P1 서연 리노컷 아바타와 음성 립싱크
Some checks are pending
API contract / OpenAPI type drift (push) Waiting to run
Some checks are pending
API contract / OpenAPI type drift (push) Waiting to run
- ClientAvatar가 리그 레지스트리(v3/rigs)로 P1을 판정해 그림 영역을 ClientAvatarV3로 그린다(로드 중·실패 시 기존 SVG, VITE_AVATAR_V3=0이면 끔) - 발화 구동 공용 모듈 speechDriver: Lab과 세션이 함께 쓰고, TTS AudioBuffer를 오디오 시계로 표본해 립싱크·발화 동반층·지문 cue를 돌린다 - Session: TTS 재생 시작 시 발화(원문·버퍼·시작 시각) 전달, 음성 실패 시 텍스트 타이밍 발화, 정지 경로에서 해제, 개방도·겉표정 강도 전달 - 정지 화면(시작 전·썸네일)도 한 프레임을 그리고, 박스 실측 폭으로 상반신/얼굴 크롭을 고른다 - 세션 발화 E2E(오디오·실패 경로), P1 레이아웃 게이트, P1 표정 시험을 v3 기대로 갱신
This commit is contained in:
parent
ecb36d123f
commit
613bcb603e
14 changed files with 1000 additions and 182 deletions
|
|
@ -337,15 +337,16 @@ test.describe("persona avatar expression rig", () => {
|
|||
await expect(page.locator(".sx-stage__now")).toContainText("온화함");
|
||||
});
|
||||
|
||||
test("uses the original SVG parameter rig for P1 Seoyeon", async ({ page }) => {
|
||||
test("uses the v3 linocut avatar for P1 Seoyeon", async ({ page }) => {
|
||||
await page.goto("/learn/session/P1");
|
||||
|
||||
const avatar = page.locator('.vg-avatar[data-persona-code="P1"]').first();
|
||||
await expect(avatar).toBeVisible();
|
||||
await expect(avatar).toHaveAttribute("data-render-mode", "svg");
|
||||
await expect(avatar).toHaveAttribute("data-affect", "sad");
|
||||
await expect(avatar.locator(".vg-raster")).toHaveCount(0);
|
||||
await expect(avatar.locator(".vg-avatar__svg")).toBeVisible();
|
||||
await expect(avatar.locator('[data-avatar-neck="true"]')).toBeVisible();
|
||||
|
||||
const v3 = avatar.locator('[data-avatar-renderer="linocut"]');
|
||||
await expect(v3).toHaveAttribute("data-load-state", "ready", { timeout: 15_000 });
|
||||
await expect(v3.locator(".linocut-avatar")).toBeVisible();
|
||||
await expect(v3).toHaveAttribute("data-viseme", "X");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
300
apps/web/e2e/avatar-session-speech.spec.ts
Normal file
300
apps/web/e2e/avatar-session-speech.spec.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
/* =====================================================================
|
||||
avatar-session-speech.spec.ts — P1 세션 TTS 재생 ↔ v3(리노컷) 아바타
|
||||
발화 연결 E2E(결정문 §8.5 "세션 연결" 테스트 계약).
|
||||
|
||||
목적: TTS 응답을 테스트 안에서 만든 WAV(유성 구간이 있는 톤버스트
|
||||
여러 개)로 모킹해, 재생 중 v3 래퍼의 data-speech-source="audio"·
|
||||
data-viseme 순환을 확인하고, 재생이 끝나면 발화 속성이 정리되는지
|
||||
본다. TTS 실패(500)면 data-speech-source="text"로 텍스트 타이밍
|
||||
발화로 떨어지는지도 본다.
|
||||
|
||||
근거: apps/web/src/pages/Session.tsx playTtsAudio/speakTextClientTurn,
|
||||
apps/web/src/components/avatar/v3/ClientAvatarV3.tsx.
|
||||
주의: 모든 API는 route fixture로 모킹한다(실제 AI 엔진·TTS 제공자 없음).
|
||||
헤드리스 자동재생 정책 때문에 이 파일만 autoplay-policy를 느슨하게
|
||||
연다(세션 범위, 다른 스펙에 영향 없음).
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test, type Locator, type Page, type Route } from "@playwright/test";
|
||||
|
||||
test.use({ launchOptions: { args: ["--autoplay-policy=no-user-gesture-required"] } });
|
||||
|
||||
const SESSION_ID = "66666666-6666-4666-8666-666666666666";
|
||||
const LEARNER_TEXT = "요즘 많이 힘들었어요. 어떤 마음이 가장 크게 남아 있나요?";
|
||||
const CLIENT_REPLY = "그냥요. 잠을 잘 못 자요. 아무 것도 하고 싶지 않아요.";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
function sseTurnBody(tokens: string[], done: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
for (const token of tokens) lines.push("event: token", `data: ${token}`, "");
|
||||
lines.push("event: done", `data: ${JSON.stringify(done)}`, "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function fulfillTurn(route: Route, tokens: string[], done: Record<string, unknown>) {
|
||||
await route.fulfill({ status: 200, contentType: "text/event-stream", body: sseTurnBody(tokens, done) });
|
||||
}
|
||||
|
||||
function writeAsciiString(view: DataView, offset: number, text: string): void {
|
||||
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
|
||||
}
|
||||
|
||||
/**
|
||||
* 유성 구간이 있는 톤버스트 여러 개로 테스트용 WAV를 만든다(16비트 PCM 모노).
|
||||
* decodeAudioData가 바로 디코드할 수 있다. 무음 구간(0)과 사인파 구간을 번갈아
|
||||
* 넣어 speechEnvelope.ts의 유성 구간 검출(포락선 > max(0.02, 0.12·P95))이
|
||||
* 서로 다른 구간 여러 개를 집어내게 한다.
|
||||
*/
|
||||
function buildToneBurstWav(): Buffer {
|
||||
const sampleRate = 16000;
|
||||
const amplitude = 0.7;
|
||||
const segments: Array<{ freq: number; durMs: number }> = [
|
||||
{ freq: 0, durMs: 150 },
|
||||
{ freq: 220, durMs: 320 },
|
||||
{ freq: 0, durMs: 180 },
|
||||
{ freq: 420, durMs: 280 },
|
||||
{ freq: 0, durMs: 180 },
|
||||
{ freq: 320, durMs: 300 },
|
||||
{ freq: 0, durMs: 220 },
|
||||
];
|
||||
|
||||
const samples: number[] = [];
|
||||
for (const seg of segments) {
|
||||
const count = Math.round((seg.durMs / 1000) * sampleRate);
|
||||
for (let i = 0; i < count; i++) {
|
||||
samples.push(seg.freq > 0 ? amplitude * Math.sin((2 * Math.PI * seg.freq * i) / sampleRate) : 0);
|
||||
}
|
||||
}
|
||||
|
||||
const dataLength = samples.length * 2;
|
||||
const buffer = Buffer.alloc(44 + dataLength);
|
||||
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
|
||||
writeAsciiString(view, 0, "RIFF");
|
||||
view.setUint32(4, 36 + dataLength, true);
|
||||
writeAsciiString(view, 8, "WAVE");
|
||||
writeAsciiString(view, 12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, 1, true); // mono
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true); // byte rate(mono·16비트)
|
||||
view.setUint16(32, 2, true); // block align
|
||||
view.setUint16(34, 16, true); // bits per sample
|
||||
writeAsciiString(view, 36, "data");
|
||||
view.setUint32(40, dataLength, true);
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const clamped = Math.max(-1, Math.min(1, samples[i]));
|
||||
view.setInt16(44 + i * 2, Math.round(clamped * 32767), true);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function routeSessionScreen(page: Page) {
|
||||
const startedAt = new Date(Date.now() - 60_000);
|
||||
|
||||
// catch-all을 먼저 등록한다 — Playwright는 나중에 등록한 route가 이긴다.
|
||||
await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this fixture" }, 404)));
|
||||
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: "00000000-0000-0000-0000-0avatarspeech",
|
||||
email: "avatar-speech.learner@hs.ac.kr",
|
||||
display_name: "학습자",
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: Math.floor(Date.now() / 1000),
|
||||
onboarding_completed_at: Math.floor(Date.now() / 1000),
|
||||
nickname: "학습자",
|
||||
self_introduction: "아바타 v3 발화 연결 E2E 검증용 학습자입니다.",
|
||||
avatar_url: "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/users/me/prepost-measures**", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
pilot_id: "phase3-pilot-draft",
|
||||
instrument_version: "pilot-prepost-scaffold-2026-06-28",
|
||||
measures: [],
|
||||
complete_pre_count: 0,
|
||||
complete_post_count: 0,
|
||||
updated_at: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/personas", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute([
|
||||
{
|
||||
code: "P1",
|
||||
display_name: "서연(가명) · 고2 · 우울/자살사고",
|
||||
difficulty: "hard",
|
||||
theory_target: ["humanistic", "cbt"],
|
||||
demographics: { age_band: "16-18", sex: "female", grade: "고2", status: "재학" },
|
||||
presenting_summary: "우울감과 자살사고 위험",
|
||||
voice_preset: null,
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route("**/api/voice/health", (route) =>
|
||||
route.fulfill(jsonRoute({ available: false, reason: "e2e fixture" })),
|
||||
);
|
||||
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute(
|
||||
{
|
||||
session_id: SESSION_ID,
|
||||
case_id: "avatar-speech-case-001",
|
||||
session_no: 1,
|
||||
stage: "라포",
|
||||
effective_openness: 0.3,
|
||||
recall_summary: null,
|
||||
degraded: false,
|
||||
},
|
||||
201,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
session_id: SESSION_ID,
|
||||
case_id: "avatar-speech-case-001",
|
||||
persona_code: "P1",
|
||||
persona_name: "서연",
|
||||
session_no: 1,
|
||||
status: "active",
|
||||
stage: "라포",
|
||||
theory_mode: "humanistic",
|
||||
effective_openness: 0.3,
|
||||
started_at: startedAt.toISOString(),
|
||||
ended_at: null,
|
||||
review_ready: false,
|
||||
turns: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// 회기 전 자기점검(pre)은 이미 원장에 잠긴 상태로 제공한다 — 이 스펙의 대상이 아니다.
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill(jsonRoute({ pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "awaiting_agents" }, 202));
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
items: [
|
||||
{
|
||||
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
checkpoint: "pre",
|
||||
status: "ready",
|
||||
learner_locked_at: startedAt.toISOString(),
|
||||
revealed_at: startedAt.toISOString(),
|
||||
error_code: null,
|
||||
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
|
||||
measurements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, (route) =>
|
||||
route.fulfill(jsonRoute({ source: "database", quota: { remaining: 3, max: 3 }, credit_events: [], events: [] })),
|
||||
);
|
||||
}
|
||||
|
||||
function doneEvent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
session_id: SESSION_ID,
|
||||
stage: "라포",
|
||||
effective_openness: 0.42,
|
||||
turn_seq: 1,
|
||||
safety_flagged: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendLearnerTurn(page: Page) {
|
||||
const input = page.getByLabel("학습자 발화 입력");
|
||||
await input.fill(LEARNER_TEXT);
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
}
|
||||
|
||||
/** 재생 중 data-viseme가 거친 서로 다른 값의 집합(관찰 상한 5초, 50ms 간격 표본). */
|
||||
async function collectDistinctVisemes(locator: Locator, minDistinct: number): Promise<Set<string>> {
|
||||
const seen = new Set<string>();
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
const viseme = await locator.getAttribute("data-viseme");
|
||||
if (viseme) seen.add(viseme);
|
||||
if (seen.size >= minDistinct) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
test.describe("P1 세션 — v3 아바타 TTS 발화 연결", () => {
|
||||
test("TTS 오디오 재생 중 립싱크가 돌고, 끝나면 발화 속성이 정리된다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
await page.route("**/api/voice/speech", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "audio/wav", body: buildToneBurstWav() }),
|
||||
);
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) => fulfillTurn(route, [CLIENT_REPLY], doneEvent()));
|
||||
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await expect(page.locator(".sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await sendLearnerTurn(page);
|
||||
|
||||
const v3 = page.locator(".sx-page--active .vg-avatar [data-avatar-renderer='linocut']");
|
||||
await expect(v3).toHaveAttribute("data-speech-source", "audio", { timeout: 15_000 });
|
||||
|
||||
const visemes = await collectDistinctVisemes(v3, 2);
|
||||
expect(visemes.size, `관찰한 비짐: ${[...visemes].join(",")}`).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await expect(v3).not.toHaveAttribute("data-speech-source", "audio", { timeout: 10_000 });
|
||||
await expect(v3).toHaveAttribute("data-viseme", "X", { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("TTS 실패(500)면 텍스트 타이밍 발화로 떨어진다", async ({ page }) => {
|
||||
await routeSessionScreen(page);
|
||||
await page.route("**/api/voice/speech", (route) => route.fulfill(jsonRoute({ detail: "tts failure fixture" }, 500)));
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) => fulfillTurn(route, [CLIENT_REPLY], doneEvent()));
|
||||
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await expect(page.locator(".sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await sendLearnerTurn(page);
|
||||
|
||||
const v3 = page.locator(".sx-page--active .vg-avatar [data-avatar-renderer='linocut']");
|
||||
await expect(v3).toHaveAttribute("data-speech-source", "text", { timeout: 15_000 });
|
||||
|
||||
const visemes = await collectDistinctVisemes(v3, 2);
|
||||
expect(visemes.size, `관찰한 비짐: ${[...visemes].join(",")}`).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -666,6 +666,62 @@ test.describe("learner session full-screen layout", () => {
|
|||
}
|
||||
});
|
||||
|
||||
// P1 서연은 v3(리노컷) 아바타로 그린다(결정문 §8.5). 위 "dense viewport" 스윕을 P1로도
|
||||
// 돌려 v3 래퍼(절대 위치·원형 클립)가 기존 SVG와 같은 레이아웃 게이트를 통과하는지 본다.
|
||||
test("keeps critical session controls visible across dense viewport sizes for the v3 avatar (P1)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await signInAsLearner(page);
|
||||
|
||||
const viewports = [
|
||||
{ width: 1366, height: 768 },
|
||||
{ width: 1180, height: 768 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 881, height: 768 },
|
||||
{ width: 820, height: 1180 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 320, height: 568 },
|
||||
];
|
||||
|
||||
await page.setViewportSize(viewports[0]);
|
||||
await page.goto("/learn/session/P1");
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await completeAlliancePreCheckpoint(page);
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
|
||||
await expectNoDocumentOverflow(page);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectSessionControlsInsideViewport(page);
|
||||
await expectNoVisibleSessionPanelOverlap(page);
|
||||
await expectMainControlsUnclipped(page);
|
||||
await expectSessionPageHeightToMatchViewport(page);
|
||||
await expectActiveSessionUsableLayout(page);
|
||||
|
||||
/* 320×568 저높이 폰은 session.css가 아바타 오브(.sx-orb-wrap)를 숨긴다(기존 SVG도 같다).
|
||||
보일 때는 v3 래퍼가 스테이지 원 자리를 정확히 차지해야 한다. */
|
||||
const orbShown = !(viewport.width === 320 && viewport.height === 568);
|
||||
const stage = page.locator(".sx-page--active .vg-avatar__stage").first();
|
||||
if (orbShown) {
|
||||
await expect(stage).toBeVisible();
|
||||
const v3 = stage.locator('[data-avatar-renderer="linocut"]');
|
||||
await expect(v3).toHaveAttribute("data-load-state", "ready", { timeout: 15_000 });
|
||||
const [stageBox, v3Box] = await Promise.all([stage.boundingBox(), v3.boundingBox()]);
|
||||
expect(stageBox, "아바타 스테이지 좌표").not.toBeNull();
|
||||
expect(v3Box, "v3 래퍼 좌표").not.toBeNull();
|
||||
for (const key of ["x", "y", "width", "height"] as const) {
|
||||
expect(Math.abs(stageBox![key] - v3Box![key]), `v3 래퍼 ${key}`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
} else {
|
||||
await expect(page.locator(".sx-page--active .sx-orb-wrap")).toBeHidden();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("does not leave an unsaved local transcript when a text turn is rejected", async ({ page }) => {
|
||||
await signInAsLearner(page);
|
||||
const persona = await fetchAvailablePersona(page, 1);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue