# AI 꾸러미 — 읽어 주기 (음성 합성) — Web Speech API — speechSynthesis
> 브라우저 음성 합성으로 게임 방법 글을 문장마다 끊어 소리 내어 읽고, 읽는 낱말을 밝혀 글을 아직 못 읽는 아이도 따라오게 한다.  
> 견본: https://ai-techstudio.web.app/#t/u78

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

## 주문서

### 만들어 줘: 읽어 주기 (음성 합성) — Web Speech API — speechSynthesis

#### 1. 목표
게임 방법 책에 「읽어 주기」를 넣어 줘 — 브라우저 음성 합성으로 문장마다 끊어 읽고, 지금 읽는 낱말을 밝혀서. 목소리는 조금 느리고 또렷한 선생님 말투.

#### 2. 핵심 기술 용어
- **Web Speech API — speechSynthesis** — 브라우저 음성 합성 (TTS)
- **SpeechSynthesisUtterance** — 읽을 글 한 덩어리 (언어 · 목소리 · 빠르기 · 높이)
- **boundary event (charIndex)** — 지금 읽는 글자 자리 — 낱말 밝히기에
- **Text-to-speech (TTS)** — 글을 소리로

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

#### 4. 조건
- speechSynthesis 가 없는 브라우저면 단추를 숨기거나 막는다 (있는지 먼저 확인)
- 읽기 전에 늘 speechSynthesis.cancel() — 앞 글과 겹치지 않게, 창을 닫을 때도 cancel
- 문장 단위로 잘라 여러 utterance 로 — 마지막 것의 onend 에서 「다 읽음」 처리
- 「—」「·」 같은 기호는 쉼표로 바꿔 이상하게 읽지 않게
- 단추는 켜고 끄는 토글 (「읽어 줘요」 ↔ 「그만 읽기」), 쪽을 넘기면 새 쪽을 이어 읽기

#### 5. 완성 기준 (이게 보이면 성공)
- 「읽어 줘요」를 누르면 지금 보이는 쪽 제목 · 본문을 한국어 목소리로 읽는다
- 읽는 낱말이 노란 띠로 차례로 밝아진다 (boundary 를 지원하는 목소리에서)
- 다음 쪽으로 넘기면 앞 쪽 읽기는 멈추고 새 쪽을 읽는다
- 언어를 바꾸면 그 언어 목소리로 읽는다

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

## 원리
- speechSynthesis.speak(utterance) 한 줄이면 기기에 깔린 목소리로 읽는다. 언어(lang) · 빠르기(rate) · 높이(pitch) 를 정한다.
- 긴 글을 한 번에 넣으면 브라우저에 따라 중간에 멈춘다 — 문장 끝(. ! ?)과 줄바꿈으로 잘라 차례로 넣는다.
- 목소리는 getVoices() 에서 화면 언어로 시작하는 것을 고른다. 사이트 언어 10개를 lang 코드로 바꾼다 (ko → ko-KR).
- 읽는 중 boundary 이벤트의 charIndex 로 지금 낱말을 찾아 밝힌다. 새로 읽기 전엔 늘 cancel() 로 앞의 것을 지운다.

## 핵심 코드 — 문장마다 끊어 읽기 (언어 · 목소리 고르기)
(발췌: src/shell/components/RulesSheet.ts speak() 를 정리 + demos/demosSystem.ts u78 의 낱말 밝히기)
```ts
const LANG: Record<string, string> = { ko: 'ko-KR', en: 'en-US', ja: 'ja-JP', zh: 'zh-TW', es: 'es-ES', pt: 'pt-BR', fr: 'fr-FR', de: 'de-DE', vi: 'vi-VN', id: 'id-ID' };
const canSpeak = () => typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window;

function speak(text: string, lang: string, onEnd: () => void, onWord?: (charIndex: number) => void) {
  speechSynthesis.cancel();                                    // 앞 글과 겹치지 않게
  const voice = speechSynthesis.getVoices().find((v) => v.lang.startsWith(lang));
  const parts = text
    .replace(/[—·]/g, ', ')                                    // 기호는 쉼표로
    .split(/(?<=[.!?。])\s+|\n+/)                            // 긴 글은 문장마다
    .map((t) => t.trim())
    .filter((t) => t && t !== '.');
  parts.forEach((t, i) => {
    const u = new SpeechSynthesisUtterance(t);
    u.lang = LANG[lang] ?? 'ko-KR';
    if (voice) u.voice = voice;
    u.rate = 0.95;
    u.pitch = 1.05;
    if (onWord) u.onboundary = (e) => onWord(e.charIndex);     // 지금 읽는 글자 자리
    if (i === parts.length - 1) u.onend = onEnd;
    speechSynthesis.speak(u);
  });
  if (!parts.length) onEnd();
}
```

## 흔한 실수 · 확인 목록
- [ ] **긴 글을 한 번에 넣으면 중간에 읽기가 멈춘다** — 문장 끝 · 줄바꿈으로 잘라 여러 utterance 로 차례로 넣는다 — 이 사이트 게임 방법 책이 그렇게 한다.
- [ ] **페이지를 막 열었을 때 getVoices() 가 빈 배열이다** — 목소리 목록은 늦게 온다. 없으면 lang 만 정해도 기본 목소리로 읽고, 필요하면 voiceschanged 이벤트 뒤에 다시 고른다.
- [ ] **창을 닫았는데 계속 읽는다** — 닫기 · 쪽 넘기기 · 「그만 읽기」에서 모두 speechSynthesis.cancel().
- [ ] **낱말 밝히기가 어떤 기기에서는 안 움직인다** — boundary 이벤트를 안 보내는 목소리가 있다. 밝히기는 덤으로 두고, 읽기 자체는 그것 없이도 되게.

## 완성 기준 체크리스트
- [ ] 「읽어 줘요」를 누르면 지금 보이는 쪽 제목 · 본문을 한국어 목소리로 읽는다
- [ ] 읽는 낱말이 노란 띠로 차례로 밝아진다 (boundary 를 지원하는 목소리에서)
- [ ] 다음 쪽으로 넘기면 앞 쪽 읽기는 멈추고 새 쪽을 읽는다
- [ ] 언어를 바꾸면 그 언어 목소리로 읽는다

## 이 기술 정보
- id: `u78` · 분류: 소리 › 소리 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 소리는 기기가 만든다 — 코드 부담은 거의 없음. 목소리 품질은 기기 · 브라우저마다 다르다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u78
- 쓰면 좋을 때: 글을 못 읽는 어린 아이용 안내 · 게임 방법 / 여러 언어 안내를 녹음 없이 내야 할 때
- 쓰지 말 때: 캐릭터 목소리 · 연기가 중요한 대사 — 녹음 파일이나 옹알이 효과음(i145) / 기기마다 목소리가 달라도 안 되는 곳 — 녹음 파일

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u78 견본 항목 — `src/demos/demosSystem.ts:889`
```ts
  u78: {
    kind: '2d',
    caption: '게임 방법 책을 소리 내어 — 읽는 낱말이 차례로 밝아져요 (자세히 보기에서 「읽어 주기」)',
    make() {
      const LINES = ['주사위를 굴려요.', '나온 수만큼 앞으로 가요.', '먼저 도착하면 이겨요!'];
      const words: { s: string; li: number; ci: number }[] = [];
      let ci = 0;
      LINES.forEach((l, li) => {
        for (const s of l.split(' ')) {
          words.push({ s, li, ci });
          ci += s.length + 1;
        }
      });
      const full = LINES.join(' ');
      let spoken = -1;
      let speaking = false;
      const speak = (): void => {
        const ss = window.speechSynthesis;
        if (!ss) return;
        ss.cancel();
        const u = new SpeechSynthesisUtterance(full);
        u.lang = 'ko-KR';
        u.rate = 0.95;
        u.onstart = () => (speaking = true);
        u.onend = () => {
          speaking = false;
          spoken = -1;
        };
        u.onboundary = (e) => {
          let wi = 0;
          words.forEach((wd, i) => {
            if (wd.ci <= e.charIndex) wi = i;
          });
          spoken = wi;
        };
        ss.speak(u);
      };
      return {
        draw(g, w, h, t) {
          stage(g, w, h, PEACH);
          const n = words.length;
          const cyc = t % (n * 0.45 + 1.5);
          const cur = speaking && spoken >= 0 ? spoken : cyc < n * 0.45 ? Math.floor(cyc / 0.45) : -1;
          // 책
          box(g, 18, 34, 284, 150, 10, '#c9733a');
          box(g, 24, 38, 134, 140, 6, '#fffaf0');
          box(g, 162, 38, 134, 140, 6, '#fffaf0');
          line(g, 160, 38, 160, 178, '#d9b48a', 2);
          // 왼쪽 쪽 그림: 주사위 · 말
          g.save();
          g.translate(70, 104);
          g.rotate(Math.sin(t * 2) * 0.15);
          box(g, -20, -20, 40, 40, 8, '#fff', '#3a2a1a', 2);
          for (const [dx, dy] of [
            [-9, -9],
            [9, 9],
            [0, 0],
            [9, -9],
            [-9, 9],
          ] as [number, number][])
            circle(g, dx, dy, 3.2, '#e8453c');
          g.restore();
          circle(g, 120, 130, 13, '#7ac8ff', '#3a6a9a', 1.5);
          face(g, 120, 130, 11);
          txt(g, '게임 방법', 91, 56, 11, '#a0520a', 'center', 800, TF);
          // 오른쪽 쪽 글
          g.font = `700 11px ${F}`;
          let li = -1;
          let x = 0;
          words.forEach((wd, i) => {
            if (wd.li !== li) {
              li = wd.li;
              x = 172;
            }
            const y = 70 + wd.li * 30;
            g.font = `700 11px ${F}`;
            const ww = g.measureText(wd.s).width;
            if (x + ww > 290) {
              x = 172;
            }
            if (i === cur) box(g, x - 2, y - 9, ww + 4, 18, 5, '#ffd23f');
            txt(g, wd.s, x, y, 11, i < cur || cur < 0 ? '#3a2a1a' : i === cur ? '#5a2a00' : '#a89a8a', 'left', 700);
            x += ww + 5;
          });
          // 스피커
          box(g, 120, 6, 80, 24, 12, '#ff7a3d');
          speaker(g, 138, 18, 6, '#fff', cur >= 0 ? 3 : 0, t);
          txt(g, '읽어 주기', 170, 18.5, 9, '#fff', 'center', 800);
        },
        controls: [
          { type: 'button', label: '읽어 주기 (소리)', on: speak },
          {
            type: 'button',
            label: '멈추기',
            on: () => {
              window.speechSynthesis?.cancel();
              speaking = false;
            },
          },
        ],
        dispose() {
          if (speaking) window.speechSynthesis?.cancel();
        },
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [소리 낮추기 (덕킹)](https://ai-techstudio.web.app/ai/t/i437.md) `i437` · [말소리 옹알이 (글자마다 짧은 음)](https://ai-techstudio.web.app/ai/t/i145.md) `i145`
- 참고 문서: [MDN — Web Speech API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API) · [MDN — SpeechSynthesisUtterance](https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance)
