본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

듣는 얼굴

~14 min · avatar, presence, animation, accessibility

Level 0음소거
0 XP0/42 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"움직임 세기는 설정으로 빼자. 디폴트는 눈치챌듯 말듯으로 가고." — 아빠, 2026-09-29

얼어붙은 것처럼 보이던 초상화

앞 레슨은 답과 답 사이에 얼굴을 움직여. 답 끝의 감정 태그가 표정을 고르지. 그런데 대화 대부분은 피파가 답하는 시간이 아니야. 아빠가 말하고 피파가 듣는 시간이고, 그동안 얼굴은 줄곧 정지한 초상화였어. 살아 있는 화면의 정지 초상화는 차분해 보이지 않아. 멈춘 것처럼 보여. 트랙 6이 마이크 쪽에서 움직이는 계량기로 푼 것과 같은 문제야. 새 애니메이션 에셋부터 아빠 말에 반응하기까지 선택지를 두고 브레인스토밍한 끝에, 아빠는 통할 수 있는 제일 싼 쪽으로 정했어. 새 에셋이 필요 없는 움직임부터, 세기는 설정으로, 기본은 눈치챌 듯 말 듯. 새 에셋은 나중에, 그것도 써 보니 필요할 때만.

신호가 들어오고, 자세가 나가

작은 모델 하나가 화면이 이미 가진 신호를 자세로 바꿔. 크기, 좌우와 위아래 이동, 회전. 화면은 그 자세를 테두리까지 포함한 얼굴 전체에 적용해. 그래서 잘리는 데가 없고 프레임 가장자리가 드러나지 않아. 신호는 루프의 단계(듣기, 기다리기, 말하기, 또는 일시정지나 멈춤일 때의 붙잡힘), 아빠의 마이크 소리 크기, 단어가 들어오고 있는지, 지금까지 확정된 전사 조각 수야. 피파가 그걸로 하는 일은 이래.

  • 숨 쉬고 떠돌아. 살아 있는 단계 어디서든. 4.2초 주기로 크기가 1퍼센트도 안 되게 오르내리는 숨, 그리고 주기가 절대 맞아떨어지지 않는 사인 셋으로 만든 느린 떠돎. 그래서 눈에 띄게 되풀이되지 않고, 어느 화면에서나 똑같아.
  • 아빠 목소리 쪽으로 기울어. 아빠가 제일 크게 말할 때 1.5퍼센트까지 커지고, 0.12초 시간 상수로 소리 크기를 따라가.
  • 끄덕여. 아빠 말 조각이 확정될 때마다 0.7초에 걸쳐 한 번.
  • 고개를 갸웃해. 아빠가 4초째 말하고 있으면 시작해서 7초에 다 기울어.
  • 살짝 위, 조금 옆을 봐. 답을 준비하는 동안.
  • 가라앉아. 멈추거나 끝나면 떠돎이 멎고 숨이 절반으로 줄어.

갸웃, 위를 보는 눈길, 가라앉기는 0.35초 시간 상수로 목표를 향해 다가가(목소리 쪽으로 기우는 건 아빠 소리를 더 빨리, 0.12초로 따라가). 그래서 단계가 바뀌어도 미끄러지듯 옮겨가지 절대 튀지 않아. 끄덕임만은 자기 모양이 있어. 멈춘 데서 시작해 멈춘 데서 끝나는 반 사인이야. 끄덕이는 도중에 새 조각이 들어오면 끄덕임이 제자리로 돌아가 처음부터 다시 시작해서 살짝 튀어. 모델은 그 튐을 숨기지 않고 그냥 받아들여. 그리고 긴 프레임 하나(숨은 탭이나 멈칫)는 최대 0.1초로 쳐서, 탭으로 돌아와도 얼굴이 제자리로 확 튀지 않아.

들을 때 어떤 얼굴을 할까

표정도 같은 신경을 써. 듣기가 시작되면 마지막 답의 감정을 1.2초 붙잡아서, 말을 멈추자마자 웃음이 싹 사라지지 않게 해. 그다음엔 warm으로 들어. 답을 준비하는 동안도 warm이고, 말하는 동안에도 답 자신의 감정을 알기 전까진 warm이야. 새 감정은 이전 감정 위로 350밀리초 동안 스며들어. 두 화면 다.

움직임은 설정이야

모든 움직임에 숫자 하나가 곱해져. 0은 정지, 1은 눈치챌 듯 말 듯(아빠가 고른 기본값), 2는 또렷하게. 웹은 Admin에서 읽고, 폰은 자기 음성 설정에 따로 가져. 시스템이 움직임 줄이기를 청하면 설정이 뭐라든 언제나 정지한 얼굴이야. 접근성이 집의 기본값보다 위야. 세 화면 모두 그 요청을 실시간으로 따라가서, 대화 도중에 바꿔도 바로 적용돼.

모델 하나, 화면 둘, 그다음 키트

웹과 폰은 같은 숫자를 들고 같은 테스트 케이스를 통과했어. 파일 하나가 다른 하나를 거울처럼 비추고, 숫자를 바꾸려면 양쪽 다 바꾸든 아무 쪽도 안 바꾸든 하라는 규칙이 붙어 있어. Firekeeper가 이 모델의 두 번째 네이티브 사용처가 되자 폰 쪽 절반은 공용 음성 키트로 올라갔고, 폰은 이제 자기 단계를 어느 모드로 옮길지만 가져. 일부러 안 만든 것도 둘 있어. 새 에셋, 그리고 아빠가 무슨 말을 했는지에 반응하기. 키워드 목록은 하드코딩이고, 전사 조각마다 모델을 부르면 문장마다 지연과 비용이야. 둘 다 써 보고 나서 정해. 트랙 8의 On Air 방송용 얼굴이 바로 이 모델 위에 서 있어.

Code

존재감 모델: 숨, 기울기, 끄덕임, 갸웃, 그리고 기분 사이의 미끄러짐·typescript
// The face's presence: signals in, a pose out. The same numbers on every surface.
type Mode = 'listening' | 'waiting' | 'speaking' | 'held';
interface Signals { mode: Mode; level: number; hearingWords: boolean; heardPieces: number }
interface Pose { scale: number; x: number; y: number; rotate: number }

const P = {
  breathSeconds: 4.2, breathScale: 0.008, heldBreath: 0.5,
  swayX: 0.008, swayY: 0.006, swayRotate: 0.6, swaySpeed: 0.5,
  leanScale: 0.015, nodSeconds: 0.7, nodY: 0.02,
  tiltAfter: 4, tiltRamp: 3, tiltRotate: 1.5, thinkY: -0.01, thinkRotate: -1.2,
  settle: 0.35, levelSeconds: 0.12, maxStep: 0.1,
};

/** Three sines whose periods never line up: a wander that doesn't visibly repeat. */
const wander = (t: number, phase: number) =>
  (Math.sin(t + phase) + 0.6 * Math.sin(1.618 * t + 1.3 + phase) + 0.3 * Math.sin(2.718 * t + 0.7 + phase)) / 1.9;

/** Ease toward a target: a change of mode is a glide, never a jump. */
const approach = (now: number, target: number, dt: number, tau: number) =>
  now + (target - now) * (1 - Math.exp(-dt / tau));

export class Presence {
  private t = 0; private level = 0; private live = 1; private think = 0; private tilt = 0;
  private pieces: number | null = null;
  private nodAt: number | null = null;
  private wordsSince: number | null = null;

  step(s: Signals, dt: number, motion: number, reducedMotion = false): Pose {
    const step = Math.max(0, Math.min(dt, P.maxStep));       // a hidden tab is not a jump
    this.t += step;
    const listening = s.mode === 'listening';
    this.level = approach(this.level, listening ? s.level : 0, step, P.levelSeconds);
    if (listening && this.pieces !== null && s.heardPieces > this.pieces) this.nodAt = this.t;
    this.pieces = s.heardPieces;
    this.wordsSince = listening && s.hearingWords ? this.wordsSince ?? this.t : null;
    const heardFor = this.wordsSince === null ? 0 : this.t - this.wordsSince;
    const tiltTarget = Math.max(0, Math.min(1, (heardFor - P.tiltAfter) / P.tiltRamp));
    this.tilt = approach(this.tilt, tiltTarget, step, P.settle);
    this.think = approach(this.think, s.mode === 'waiting' ? 1 : 0, step, P.settle);
    this.live = approach(this.live, s.mode === 'held' ? 0 : 1, step, P.settle);
    let nod = 0;
    if (this.nodAt !== null) {
      const progress = (this.t - this.nodAt) / P.nodSeconds;
      if (progress >= 1) this.nodAt = null; else nod = Math.sin(Math.PI * progress);
    }
    const amount = reducedMotion ? 0 : Math.max(0, Math.min(2, motion));
    if (amount === 0) return { scale: 1, x: 0, y: 0, rotate: 0 };
    const w = this.t * P.swaySpeed;
    const breath = Math.sin((2 * Math.PI * this.t) / P.breathSeconds)
      * (P.heldBreath + (1 - P.heldBreath) * this.live);
    return {
      scale: 1 + amount * (P.breathScale * breath + P.leanScale * this.level),
      x: amount * P.swayX * this.live * wander(w, 0),
      y: amount * (P.swayY * this.live * wander(w, 2.1) + P.nodY * nod + P.thinkY * this.think),
      rotate: amount * (P.swayRotate * this.live * wander(w, 4.2) + P.tiltRotate * this.tilt
        + P.thinkRotate * this.think),
    };
  }
}

const face = new Presence();
const marks = new Set([0, 10, 33, 50, 63, 80, 95, 110]);   // tenths of a second worth printing
for (let i = 0; i <= 110; i += 1) {
  const t = i / 10;
  const mode: Mode = t < 9 ? 'listening' : 'waiting';
  const talking = t >= 1 && t < 8.5;
  const pieces = t >= 6 ? 2 : t >= 3 ? 1 : 0;                // two pieces of his words land
  const pose = face.step({ mode, level: talking ? 0.7 : 0.05, hearingWords: talking,
    heardPieces: pieces }, 0.1, 1);
  if (marks.has(i)) {
    console.log(`${t.toFixed(1).padStart(4)}s ${mode.padEnd(9)} scale ${pose.scale.toFixed(4)}`
      + `  y ${(pose.y * 100).toFixed(2)}%  rotate ${pose.rotate.toFixed(2)}°`);
  }
}
console.log('reduced motion:', face.step(
  { mode: 'listening', level: 1, hearingWords: true, heardPieces: 2 }, 0.1, 2, true));

External links

Exercise

npx tsx로 모델을 돌려서 여덟 줄을 읽어봐. 아빠가 말하는 동안의 기울기, 조각마다의 끄덕임, 말이 4초 이어진 뒤의 갸웃, 답을 준비할 때 위를 보는 걸 찾아. 그다음 움직임을 0과 2로 돌려봐. 마지막으로 붙잡힘 구간(일시정지)을 넣고, 떠돎이 뚝 멈추는 게 아니라 서서히 사라지고 숨이 절반으로 줄어드는지 확인해.
Hint
끄덕임은 조각이 자리 잡고 1초도 안 돼서 y에 보이고, 0.35초쯤에 제일 깊어. 0.7초짜리 반 사인이거든. 붙잡힘 구간에선 살아 있음 값이 0 쪽으로 다가가면서 떠돎을 흐리게 하고 숨을 절반 쪽으로 줄여. 살아 있음 값도 갸웃, 위를 보는 눈길과 같은 시간 상수로 다가가니까 아무것도 딱 끊기지 않아.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.