- 추천 원화(P2 b·P3 v2b·P4 v2b·P5 v2a·P6 v2b·P7 v2b)로 얼굴 없는 기본형·몸 원본 codex 편집, 정렬·정규화 후 공통 파이프라인 실행 - 게시 자산 apps/web/public/avatar/v3/p2~p7, 리그 rigs/p2Rig~p7Rig - 세션 레지스트리 P1~P7 전원 활성, Lab은 생성된 리그 자동 수집 - P3·P6 기본 눈 감김을 줄여 중립 렌더를 원화 눈매에 맞춤 - 표정 E2E를 v3 렌더러 기대로 갱신(검사 의도 유지)
675 lines
24 KiB
TypeScript
675 lines
24 KiB
TypeScript
/* =====================================================================
|
|
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 { labLinocutRigs } from "../components/avatar/v3/rigs";
|
|
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 type { Performance, PerformanceCue } from "../components/avatar/engine/performance";
|
|
import type { PhraseKind, SpeechShape, VisemeId } from "../components/avatar/engine/lipsync";
|
|
import { startSpeech, type SpeechHandle } from "../components/avatar/engine/speechDriver";
|
|
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 렌더러를 강제한다.
|
|
공통 파이프라인이 만든 pNRig.ts를 전부 모은 값이라, 새 페르소나가 빌드되는
|
|
즉시(재빌드 없이) 이 Lab에서 보인다 — v3/rigs/index.ts labLinocutRigs() 참고. */
|
|
const LINOCUT_RIGS: Partial<Record<string, LinocutRig>> = labLinocutRigs();
|
|
|
|
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;
|
|
},
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** ?persona=P3 같은 쿼리로 처음 고를 페르소나를 정한다(캡처 도구용). 모르는 코드거나
|
|
없으면 기본 P1. */
|
|
function personaFromQuery(): string {
|
|
const raw = new URLSearchParams(window.location.search).get("persona");
|
|
const upper = raw?.toUpperCase() ?? "";
|
|
return PERSONA_OPTIONS.some((p) => p.code === upper) ? upper : "P1";
|
|
}
|
|
|
|
export default function AvatarLab() {
|
|
const seed = useMemo(seedFromQuery, []);
|
|
const clock = useRef<Clock>(createClock()).current;
|
|
|
|
const [personaCode, setPersonaCode] = useState(personaFromQuery);
|
|
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 speechHandleRef = useRef<SpeechHandle | null>(null);
|
|
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 () => {
|
|
speechHandleRef.current?.stop();
|
|
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 };
|
|
}
|
|
|
|
function playSpeech(): void {
|
|
speechHandleRef.current?.stop();
|
|
const nowMs = clock.now();
|
|
const speech = demeanorFor(personaCode).speech;
|
|
setAvatarState("speaking");
|
|
engine.setState("speaking", nowMs);
|
|
const handle = startSpeech({
|
|
engine,
|
|
text: speechText,
|
|
expression: selectedExpression,
|
|
intensity,
|
|
openness,
|
|
seed,
|
|
speech,
|
|
now: clock.now,
|
|
onFrame: ({ viseme, shape, phraseKind, stressed }) => {
|
|
speechDisplayRef.current = { viseme, shape };
|
|
coSpeechDisplayRef.current = { phraseKind, stressed };
|
|
},
|
|
onEnd: () => endSpeech(clock.now()),
|
|
});
|
|
speechHandleRef.current = handle;
|
|
setParsedCues(handle.cues);
|
|
setUnmatched(handle.unmatched);
|
|
}
|
|
|
|
function handleAudioFileChange(e: ChangeEvent<HTMLInputElement>): void {
|
|
setAudioFileName(e.target.files?.[0]?.name ?? null);
|
|
}
|
|
|
|
/** "오디오 파일로 말하기" — 로컬 오디오를 디코드해 speechDriver에 넘긴다(포락선 선분석·
|
|
오디오 시계 표본은 startSpeech 안에서 한다). */
|
|
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));
|
|
|
|
try {
|
|
audioSourceRef.current?.stop();
|
|
} catch {
|
|
/* 이미 정지된 소스면 무시한다. */
|
|
}
|
|
const source = ctx.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
source.connect(ctx.destination);
|
|
audioSourceRef.current = source;
|
|
|
|
const startAtCtx = ctx.currentTime + 0.3;
|
|
source.start(startAtCtx);
|
|
|
|
speechHandleRef.current?.stop();
|
|
const nowMs = clock.now();
|
|
const speech = demeanorFor(personaCode).speech;
|
|
setAvatarState("speaking");
|
|
engine.setState("speaking", nowMs);
|
|
const handle = startSpeech({
|
|
engine,
|
|
text: speechText,
|
|
expression: selectedExpression,
|
|
intensity,
|
|
openness,
|
|
seed,
|
|
speech,
|
|
now: clock.now,
|
|
audio: { buffer: audioBuffer, context: ctx, startAt: startAtCtx },
|
|
onFrame: ({ viseme, shape, phraseKind, stressed }) => {
|
|
speechDisplayRef.current = { viseme, shape };
|
|
coSpeechDisplayRef.current = { phraseKind, stressed };
|
|
},
|
|
onEnd: () => endSpeech(clock.now()),
|
|
});
|
|
speechHandleRef.current = handle;
|
|
setParsedCues(handle.cues);
|
|
setUnmatched(handle.unmatched);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|