# AI 꾸러미 — 내레이션 동기화 (낱말 시각표) — speechSynthesis
> 내레이션을 낱말 단위 시각표로 — 그림 · 자막 · 목소리 · 음악이 같은 시계를 따라감 (음성 합성 speechSynthesis 또는 TTS 파일)  
> 견본: https://ai-techstudio.web.app/#t/i81

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 내레이션 동기화 (낱말 시각표) — speechSynthesis

#### 1. 목표
게임 방법 읽어 주기에 「내레이션 동기화 (낱말 시각표)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 내레이션을 낱말 단위 시각표로 — 그림 · 자막 · 목소리 · 음악이 같은 시계를 따라감 (음성 합성 speechSynthesis 또는 TTS 파일)

#### 2. 핵심 기술 용어
- **speechSynthesis**
- **TTS**

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 화면 코드와 계산 코드를 나눠서 (계산은 순수 함수)
- 한 프레임 16ms 안 — 오래 걸리는 계산은 나눠서

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「내레이션을 낱말 단위 시각표로 — 그림 · 자막 · 목소리 · 음악이 같은 시계를 따라감 (음성 합성 speechSynthesis 또는 TTS 파일)」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 내레이션을 낱말 단위 시각표로 — 그림 · 자막 · 목소리 · 음악이 같은 시계를 따라감 (음성 합성 speechSynthesis 또는 TTS 파일)


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「내레이션을 낱말 단위 시각표로 — 그림 · 자막 · 목소리 · 음악이 같은 시계를 따라감 (음성 합성 speechSynthesis 또는 TTS 파일)」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i81` · 분류: 물리 · 수학 원리 › 원리 설명 · 구조 · 공통 · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i81
- 쓰면 좋을 때: 쓰면 좋을 곳 — 게임 방법 읽어 주기 · 수학 이야기 · 접근성

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i81 견본 항목 — `src/demos/demosWhiteboard.ts:336`
```ts
  i81: {
    kind: '2d',
    caption: '내레이션을 낱말 단위 시각표로 — 그림 · 자막 · 소리가 같은 시계를 따라가요 (읽어 주기는 단추로)',
    make() {
      const lines = leidenfrost().map((s) => ({ t0: s.t0, t1: s.t1, words: s.say.split(' ') }));
      const L = 30;
      let speaking = false;
      return {
        draw(g, w, h, t) {
          const T0 = t % L;
          g.fillStyle = '#fbfaf6';
          g.fillRect(0, 0, w, h);
          const pad = w * 0.05;
          const tw = w - pad * 2;
          const x = (s: number): number => pad + (s / L) * tw;
          const fs = Math.max(9, Math.min(15, w / 45));
          g.font = `600 ${fs}px "Pretendard Variable", system-ui`;
          // 줄: 그림 · 자막 · 소리 · 음악
          const rows = ['그림', '자막', '목소리', '음악'];
          const rh = (h * 0.5) / rows.length;
          const top = h * 0.12;
          rows.forEach((r, i) => {
            g.fillStyle = '#666';
            g.textAlign = 'left';
            g.fillText(r, 6, top + rh * i + rh * 0.62);
          });
          lines.forEach((ln, i) => {
            const c = ['#2f7be0', '#e2483a', '#3aa86a'][i]!;
            // 그림
            g.fillStyle = c + '33';
            g.fillRect(x(ln.t0), top + 4, x(ln.t1) - x(ln.t0) - 3, rh - 8);
            // 자막 · 목소리 — 낱말마다 칸
            const n = ln.words.length;
            for (let k = 0; k < n; k++) {
              const a = ln.t0 + ((ln.t1 - ln.t0 - 1) * k) / n;
              const b2 = ln.t0 + ((ln.t1 - ln.t0 - 1) * (k + 1)) / n;
              const on = T0 >= a && T0 < b2;
              g.fillStyle = on ? c : c + '55';
              g.fillRect(x(a), top + rh + 6, x(b2) - x(a) - 1.5, rh - 12);
              g.fillRect(x(a), top + rh * 2 + 6 + (rh - 12) * 0.3 * (1 - Math.abs(Math.sin(k * 1.7))), x(b2) - x(a) - 1.5, (rh - 12) * (0.7 + 0.3 * Math.abs(Math.sin(k * 1.7))));
            }
          });
          // 음악 — 잔잔한 물결
          g.strokeStyle = '#b48ad8';
          g.lineWidth = 1.5;
          g.beginPath();
          for (let i = 0; i <= 200; i++) {
            const s = (i / 200) * L;
            const y = top + rh * 3 + rh / 2 + Math.sin(s * 3) * rh * 0.18 * (0.6 + 0.4 * Math.sin(s * 0.7));
            if (i) g.lineTo(x(s), y);
            else g.moveTo(x(s), y);
          }
          g.stroke();
          // 재생 머리
          g.strokeStyle = '#1d1d1f';
          g.lineWidth = 2;
          g.beginPath();
          g.moveTo(x(T0), top - 6);
          g.lineTo(x(T0), top + rh * 4);
          g.stroke();
          // 지금 자막
          const ln = lines.find((l) => T0 >= l.t0 && T0 < l.t1) ?? lines[0]!;
          const k = Math.min(ln.words.length - 1, Math.floor(((T0 - ln.t0) / Math.max(1, ln.t1 - ln.t0 - 1)) * ln.words.length));
          g.textAlign = 'center';
          g.font = `700 ${fs * 1.3}px Gaegu, "Pretendard Variable", system-ui`;
          const words = ln.words.map((wd, i) => ({ wd, on: i === k, past: i < k }));
          const full = ln.words.join(' ');
          const W = g.measureText(full).width;
          let cx = w / 2 - Math.min(W, w * 0.94) / 2;
          const sc = Math.min(1, (w * 0.94) / W);
          g.save();
          g.translate(cx, h * 0.83);
          g.scale(sc, sc);
          let px = 0;
          g.textAlign = 'left';
          for (const it of words) {
            g.fillStyle = it.on ? '#e2483a' : it.past ? '#1d1d1f' : '#aaa';
            g.fillText(it.wd, px, 0);
            px += g.measureText(it.wd + ' ').width;
          }
          g.restore();
          cx = 0;
          void cx;
          g.fillStyle = '#888';
          g.font = `500 ${fs * 0.85}px "Pretendard Variable", system-ui`;
          g.fillText(`${T0.toFixed(1)}초 / ${L}초`, w / 2, h * 0.95);
        },
        controls: [
          {
            type: 'button',
            label: '읽어 주기 (한 장면씩)',
            on: () => {
              if (!('speechSynthesis' in window)) return;
              speechSynthesis.cancel();
              speaking = true;
              for (const l of lines) {
                const u = new SpeechSynthesisUtterance(l.words.join(' '));
                u.lang = 'ko-KR';
                speechSynthesis.speak(u);
              }
            },
          },
          { type: 'button', label: '멈추기', on: () => ('speechSynthesis' in window ? speechSynthesis.cancel() : undefined) },
        ],
        dispose() {
          if (speaking && 'speechSynthesis' in window) speechSynthesis.cancel();
        },
      };
    },
  }
```
