Files

2746 lines
130 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> find-word@0.1.0 test
> vitest run tests/neural-release.test.ts tests/App.test.tsx -t 0.1.1|브랜드|고정 venv
RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1.1
❯ tests/neural-release.test.ts (2 tests | 2 failed) 55ms
× 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다 23ms
× 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다 30ms
❯ tests/App.test.tsx (13 tests | 1 failed | 12 skipped) 242ms
❯ 시작부터 결과까지 사용자 흐름 (13)
× 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다 241ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다
TestingLibraryElementError: Unable to find an element with the text: v0.1.1. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
Ignored nodes: comments, script, style
<body>
<div>
<a
class="skip-link"
href="#main"
>
본문으로 건너뛰기
</a>
<header
class="header wrap"
>
<div
class="brand"
>
<span
aria-hidden="true"
class="brand-mark"
>
<i />
<i />
<i />
<i />
</span>
<h1>
단어를 맞춰라
</h1>
<span
class="version"
>
v0.1
</span>
</div>
<div
class="header-actions"
>
<a
href="#howto"
>
플레이 방법 
<span
aria-hidden="true"
>
↓
</span>
</a>
<span
class="little-dot"
/>
소리에 집중하는 시간
</div>
</header>
<main
class="wrap"
id="main"
tabindex="-1"
>
<section
aria-labelledby="hero-title"
class="hero"
>
<div
class="hero-copy"
>
<p
class="eyebrow"
>
<span />
 귀를 기울이면, 발견하는 즐거움
</p>
<h2
id="hero-title"
>
소음 속 작은 단어,
<br />
<em>
들리시나요?
</em>
</h2>
<p
class="lead"
>
도시의 소리 사이에 에스가 숨겨 둔 단어.
<br />
조금 더 귀 기울여, 오늘의 행운을 찾아보세요.
</p>
<div
class="hero-meta"
>
<span>
10개의 질문
</span>
<span>
5단계 도전
</span>
<span>
나만의 속도로
</span>
</div>
</div>
<div
class="sound-room"
>
<div
class="room-top"
>
<span>
<i />
 에스의 사운드 룸
</span>
<span>
8
가지 환경
</span>
</div>
<div
class="orbit orbit-one"
/>
<div
class="orbit orbit-two"
/>
<span
aria-hidden="true"
class="floating-note note-one"
>
♪
</span>
<span
aria-hidden="true"
class="floating-note note-two"
>
✳
</span>
<div
class="speech-bubble"
>
행운의 단어를 찾아보거라!
</div>
<div
aria-hidden="true"
class="mascot "
>
<span
class="ear left"
/>
<span
class="ear right"
/>
<span
class="eye left"
/>
<span
class="eye right"
/>
<span
class="smile"
/>
<b>
S
</b>
</div>
<div
class="room-bottom"
>
<div
aria-hidden="true"
class="sound-wave "
>
<i
style="height: 10px; animation-delay: 0s;"
/>
<i
style="height: 33.799955450859244px; animation-delay: 0.05s;"
/>
<i
style="height: 16.132986448643948px; animation-delay: 0.1s;"
/>
<i
style="height: 32.219552375865575px; animation-delay: 0.15000000000000002s;"
/>
<i
style="height: 21.858720427326595px; animation-delay: 0.2s;"
/>
<i
style="height: 29.163690702963766px; animation-delay: 0.25s;"
/>
<i
style="height: 26.796992502245015px; animation-delay: 0.30000000000000004s;"
/>
<i
style="height: 24.835290693688798px; animation-delay: 0.35000000000000003s;"
/>
<i
style="height: 30.619883556555898px; animation-delay: 0.4s;"
/>
<i
style="height: 35.39139668035928px; animation-delay: 0.45s;"
/>
<i
style="height: 71.52943948029164px; animation-delay: 0.5s;"
/>
<i
style="height: 19.53593765210872px; animation-delay: 0.55s;"
/>
<i
style="height: 73.98674560913082px; animation-delay: 0.6000000000000001s;"
/>
<i
style="height: 16.952742107140878px; animation-delay: 0.65s;"
/>
<i
style="height: 72.19510052760725px; animation-delay: 0.7000000000000001s;"
/>
<i
style="height: 32.979734657418774px; animation-delay: 0.75s;"
/>
<i
style="height: 66.27347594564635px; animation-delay: 0.8s;"
/>
<i
style="height: 47.480789758355044px; animation-delay: 0.8500000000000001s;"
/>
<i
style="height: 56.615089141222136px; animation-delay: 0.9s;"
/>
<i
style="height: 59.492984932238116px; animation-delay: 0.9500000000000001s;"
/>
<i
style="height: 43.861291911681526px; animation-delay: 1s;"
/>
<i
style="height: 68.21866699734562px; animation-delay: 1.05s;"
/>
<i
style="height: 28.858982496016484px; animation-delay: 1.1s;"
/>
<i
style="height: 73.07841912260051px; animation-delay: 1.1500000000000001s;"
/>
<i
style="height: 12.60436847039135px; animation-delay: 1.2000000000000002s;"
/>
<i
style="height: 73.74953619965402px; animation-delay: 1.25s;"
/>
<i
style="height: 23.8231850360459px; animation-delay: 1.3s;"
/>
<i
style="height: 32.57029511072814px; animation-delay: 1.35s;"
/>
<i
style="height: 20.99981090780238px; animation-delay: 1.4000000000000001s;"
/>
<i
style="height: 29.735764963203792px; animation-delay: 1.4500000000000002s;"
/>
<i
style="height: 26.085500220240995px; animation-delay: 1.5s;"
/>
<i
style="height: 25.59070868046883px; animation-delay: 1.55s;"
/>
<i
style="height: 30.10305417152866px; animation-delay: 1.6s;"
/>
<i
style="height: 20.410372983416416px; animation-delay: 1.6500000000000001s;"
/>
<i
style="height: 32.785692656480826px; animation-delay: 1.7000000000000002s;"
/>
</div>
<span>
소음 사이, 숨겨진 한마디
</span>
</div>
</div>
</section>
<section
aria-labelledby="setup-title"
class="setup-card"
>
<div
class="setup-heading"
>
<span
class="section-number"
>
01
</span>
<div>
<h2
id="setup-title"
>
나의 소리 탐험 준비
</h2>
<p>
편한 방식으로 시작하세요. 난이도는 조금씩 높아져요.
</p>
</div>
<span
class="round-badge"
>
약 3–5분
</span>
</div>
<div
class="setup-grid"
>
<fieldset
class="mode-field"
>
<legend>
어떻게 답할까요?
</legend>
<div
class="mode-options"
>
<label
class="mode-option selected"
>
<input
aria-label="골고루 풀기"
checked=""
name="mode"
type="radio"
value="mixed"
/>
<span
aria-hidden="true"
class="mode-icon"
>
◈
</span>
<strong>
골고루 풀기
</strong>
<small>
선택 + 직접 입력
</small>
</label>
<label
class="mode-option "
>
<input
aria-label="보기에서 선택"
name="mode"
type="radio"
value="choice"
/>
<span
aria-hidden="true"
class="mode-icon"
>
☷
</span>
<strong>
보기에서 선택
</strong>
<small>
4개 단어 중 하나
</small>
</label>
<label
class="mode-option "
>
<input
aria-label="직접 입력"
name="mode"
type="radio"
value="text"
/>
<span
aria-hidden="true"
class="mode-icon"
>
✎
</span>
<strong>
직접 입력
</strong>
<small>
기억한 단어 쓰기
</small>
</label>
</div>
</fieldset>
<div
class="environment-field"
>
<label
for="environment"
>
배경 환경
</label>
<select
id="environment"
>
<option
value="rotate"
>
여덟 가지 일상 속으로
</option>
<option
value="street"
>
길거리
</option>
<option
value="mall"
>
쇼핑몰
</option>
<option
value="theater"
>
극장
</option>
<option
value="department"
>
백화점
</option>
<option
value="market"
>
시장
</option>
<option
value="subway"
>
지하철
</option>
<option
value="cafe"
>
카페
</option>
<option
value="rain"
>
비 오는 거리
</option>
</select>
<p>
길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요.
</p>
<div
class="environment-chips"
>
<span>
길거리
</span>
<span>
카페
</span>
<span>
지하철
</span>
<span>
+5개 환경
</span>
</div>
</div>
</div>
<div
class="volume-row"
>
<label
for="volume"
>
<span
aria-hidden="true"
>
◖))
</span>
 전체 음량
</label>
<input
id="volume"
max="100"
min="0"
type="range"
value="35"
/>
<output
for="volume"
>
35
%
</output>
</div>
<div
class="start-row"
>
<p>
<span
aria-hidden="true"
>
♬
</span>
 편안한 작은 음량으로 시작해 보세요.
</p>
<button
class="primary"
>
게임 시작 · 소리 듣기 
<span
aria-hidden="true"
>
→
</span>
</button>
</div>
</section>
<section
aria-labelledby="howto-title"
class="howto"
id="howto"
>
<h2
id="howto-title"
>
작은 단어를 발견하는 방법
</h2>
<div
class="steps"
>
<article>
<span>
01
</span>
<h3>
먼저, 귀 기울이기
</h3>
<p>
에스의 안내와 소음 속 문장을 들어요.
<br />
처음에는 또렷하게, 점점 더 작게.
</p>
</article>
<article>
<span>
02
</span>
<h3>
기억한 단어 찾기
</h3>
<p>
소리가 끝나면 질문이 나타나요.
<br />
보기에서 고르거나 직접 입력해요.
</p>
</article>
<article>
<span>
03
</span>
<h3>
나의 발견 돌아보기
</h3>
<p>
열 문제 뒤 나의 결과를 확인해요.
<br />
놓쳤다면 언제든 다시 들어도 좋아요.
</p>
</article>
</div>
</section>
</main>
<footer
class="footer wrap"
>
<p>
Designed by OZ at Heat-T Medical Inc.
</p>
<p>
청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다.
</p>
<span>
개인정보 수집 없이, 오직 플레이만.
</span>
</footer>
</div>
</body>
❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19
❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38
❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17
❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19
❯ tests/App.test.tsx:204:19
202| }),
203| ).toBeInTheDocument();
204| expect(screen.getByText("v0.1.1")).toBeInTheDocument();
| ^
205| expect(f.audio.play).not.toHaveBeenCalled();
206| expect(
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/21]⎯
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: expected '0.1.0' to be '0.1.1' // Object.is equality
Expected: "0.1.1"
Received: "0.1.0"
❯ tests/neural-release.test.ts:9:28
7| const pkg = JSON.parse(readFileSync("package.json", "utf8"));
8| const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
9| expect.soft(pkg.version).toBe("0.1.1");
| ^
10| expect.soft(lock.version).toBe("0.1.1");
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/21]⎯
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: expected '0.1.0' to be '0.1.1' // Object.is equality
Expected: "0.1.1"
Received: "0.1.0"
❯ tests/neural-release.test.ts:10:29
8| const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
9| expect.soft(pkg.version).toBe("0.1.1");
10| expect.soft(lock.version).toBe("0.1.1");
| ^
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/21]⎯
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: expected '0.1.0' to be '0.1.1' // Object.is equality
Expected: "0.1.1"
Received: "0.1.0"
❯ tests/neural-release.test.ts:11:42
9| expect.soft(pkg.version).toBe("0.1.1");
10| expect.soft(lock.version).toBe("0.1.1");
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
| ^
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/21]⎯
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: src/App.tsx: expected 'import { useEffect, useRef, useState …' to contain '0.1.1'
- Expected
+ Received
- 0.1.1
+ import { useEffect, useRef, useState } from "react";
+ import { AudioEngine, type AudioPort } from "./audio/engine";
+ import { ENVIRONMENTS, LEVELS } from "./content";
+ import {
+ createQuestions,
+ gameReducer,
+ initialGame,
+ normalizeAnswer,
+ summarize,
+ type Action,
+ type GameState,
+ type Mode,
+ type Question,
+ } from "./game";
+
+ // 화면은 순수 reducer의 단계만 표시한다. 엔진과 난수 seed를 주입할 수 있어
+ // 사용자 흐름을 실제 장치 없이도 재현하고 별도로 실제 브라우저에서 검증한다.
+ export interface AppProps {
+ audio?: AudioPort;
+ seed?: number;
+ }
+
+ function SoundWave({ active = false }: { active?: boolean }) {
+ // 파형은 장식이며 답의 글자 수나 실제 음성 데이터를 노출하지 않는다.
+ return (
+ <div className={`sound-wave ${active ? "active" : ""}`} aria-hidden="true">
+ {Array.from({ length: 35 }, (_, i) => (
+ <i
+ key={i}
+ style={{
+ height: `${10 + Math.abs(Math.sin(i * 1.7)) * (i > 8 && i < 27 ? 64 : 24)}px`,
+ animationDelay: `${i * 0.05}s`,
+ }}
+ />
+ ))}
+ </div>
+ );
+ }
+
+ function Mascot({ small = false }: { small?: boolean }) {
+ // CSS로 직접 그린 캐릭터라 외부 이미지·폰트·저작권 불명 자산이 필요 없다.
+ return (
+ <div className={`mascot ${small ? "small" : ""}`} aria-hidden="true">
+ <span className="ear left" />
+ <span className="ear right" />
+ <span className="eye left" />
+ <span className="eye right" />
+ <span className="smile" />
+ <b>S</b>
+ </div>
+ );
+ }
+
+ // 난이도의 dB는 배경 대비 음성의 상대 크기다. 게임·결과에서 같은 표기를
+ // 쓰고 부호의 의미를 풀어 설명하되 엔진에 전달하는 수치는 바꾸지 않는다.
+ function formatSnr(db: number) {
+ return `SNR ${db > 0 ? "+" : ""}${db} dB`;
+ }
+
+ function describeSnr(db: number) {
+ if (db === 0) return "음성과 배경의 크기가 같아요.";
+ return `음성이 배경보다 ${Math.abs(db)} dB ${db > 0 ? "커요" : "작아요"}.`;
+ }
+
+ export default function App({ audio, seed }: AppProps) {
+ const [engine] = useState<AudioPort>(() => audio ?? new AudioEngine());
+ const [mode, setMode] = useState<Mode>("mixed");
+ const [environment, setEnvironment] = useState("rotate");
+ const [volume, setVolume] = useState(35);
+ const [game, setGame] = useState<GameState | null>(null);
+ const [input, setInput] = useState("");
+ const [attempt, setAttempt] = useState(0);
+ const token = useRef(0);
+ const playing = useRef(false);
+ const introDone = useRef(false);
+ // 한글 IME의 Enter는 글자 조합 확정에도 사용된다. 조합 중 form 제출은
+ // 막고 확정 뒤 명시적인 제출만 채점하여 모바일·데스크톱 입력을 보호한다.
+ const composing = useRef(false);
+ const focusTarget = useRef<HTMLHeadingElement>(null);
+ const startButton = useRef<HTMLButtonElement>(null);
+ const dispatch = (action: Action) =>
+ setGame((previous) =>
+ previous ? gameReducer(previous, action) : previous,
+ );
+
+ useEffect(
+ () => () => {
+ token.current++;
+ playing.current = false;
+ engine.dispose();
+ },
+ [engine],
+ );
+ useEffect(() => {
+ // 단계가 바뀌면 제목으로 초점을 옮겨 스크린리더·키보드 사용자 모두 새
+ // 질문/피드백의 위치를 알게 한다. 입력 중 재렌더에는 초점을 빼앗지 않는다.
+ if (
+ game?.phase === "answer" ||
+ game?.phase === "feedback" ||
+ game?.phase === "results" ||
+ game?.phase === "ready"
+ )
+ focusTarget.current?.focus();
+ }, [game?.phase, game?.index]);
+
+ const run = async (question: Question) => {
+ if (playing.current) return;
+ // 다시 듣기로 입력 DOM이 사라질 때 compositionend가 생략되는 브라우저도
+ // 있으므로 재생 경계에서 조합 잠금을 해제한다. 입력 값 자체는 유지한다.
+ composing.current = false;
+ playing.current = true;
+ const currentToken = ++token.current;
+ dispatch({ type: "PLAY" });
+ try {
+ await engine.play(question, !introDone.current, () => {
+ if (currentToken === token.current) dispatch({ type: "STARTED" });
+ });
+ if (currentToken !== token.current) return;
+ introDone.current = true;
+ dispatch({ type: "ENDED" });
+ } catch (error) {
+ if (currentToken !== token.current) return;
+ // 내부 URL·예외 스택 대신 이해할 수 있는 복구 동작을 안내한다.
+ const detail =
+ error instanceof Error && error.name === "AbortError"
+ ? "듣기를 중단했습니다. 다시 시도해 주세요."
+ : "소리를 재생하지 못했어요. 브라우저의 소리 허용과 연결 상태를 확인한 뒤 다시 시도해 주세요.";
+ dispatch({ type: "ERROR", message: detail });
+ } finally {
+ if (currentToken === token.current) playing.current = false;
+ }
+ };
+
+ const begin = () => {
+ const questions = createQuestions(
+ (seed ?? Date.now()) + attempt,
+ mode,
+ environment,
+ );
+ introDone.current = false;
+ composing.current = false;
+ setInput("");
+ setGame(initialGame(questions));
+ // 사용자 클릭 호출 스택에서 엔진을 열어 자동 재생 정책을 준수한다.
+ void run(questions[0]!);
+ };
+ const home = () => {
+ token.current++;
+ playing.current = false;
+ engine.stop();
+ composing.current = false;
+ setGame(null);
+ setInput("");
+ setAttempt((a) => a + 1);
+ requestAnimationFrame(() => startButton.current?.focus());
+ };
+ const next = () => {
+ composing.current = false;
+ setInput("");
+ dispatch({ type: "NEXT" });
+ };
+ const question = game?.questions[game.index];
+ const result = game ? summarize(game) : null;
+ const busy = game?.phase === "loading" || game?.phase === "playing";
+
+ // 상태와 엔진 연결은 공유하고 각 화면에는 컨트롤 하나만 배치한다.
+ // 위치가 바뀌어도 기존 접근성 이름·label 연결·사용자가 정한 음량을 유지한다.
+ const volumeControl = (
+ <div className="volume-row">
+ <label htmlFor="volume">
+ <span aria-hidden="true">◖))</span> 전체 음량
+ </label>
+ <input
+ id="volume"
+ type="range"
+ min="0"
+ max="100"
+ value={volume}
+ onChange={(event) => {
+ const value = Number(event.target.value);
+ setVolume(value);
+ engine.setVolume(value / 100);
+ }}
+ />
+ <output htmlFor="volume">{volume}%</output>
+ </div>
+ );
+
+ return (
+ <>
+ <a className="skip-link" href="#main">
+ 본문으로 건너뛰기
+ </a>
+ <header className="header wrap">
+ <div className="brand">
+ <span className="brand-mark" aria-hidden="true">
+ <i />
+ <i />
+ <i />
+ <i />
+ </span>
+ <h1>단어를 맞춰라</h1>
+ <span className="version">v0.1</span>
+ </div>
+ <div className="header-actions">
+ {game ? (
+ <button className="text-button" onClick={home}>
+ 처음으로
+ </button>
+ ) : (
+ <a href="#howto">
+ 플레이 방법 <span aria-hidden="true">↓</span>
+ </a>
+ )}
+ <span className="little-dot" />
+ 소리에 집중하는 시간
+ </div>
+ </header>
+
+ <main id="main" className="wrap" tabIndex={-1}>
+ {!game ? (
+ <>
+ <section className="hero" aria-labelledby="hero-title">
+ <div className="hero-copy">
+ <p className="eyebrow">
+ <span /> 귀를 기울이면, 발견하는 즐거움
+ </p>
+ <h2 id="hero-title">
+ 소음 속 작은 단어,
+ <br />
+ <em>들리시나요?</em>
+ </h2>
+ <p className="lead">
+ 도시의 소리 사이에 에스가 숨겨 둔 단어.
+ <br />
+ 조금 더 귀 기울여, 오늘의 행운을 찾아보세요.
+ </p>
+ <div className="hero-meta">
+ <span>10개의 질문</span>
+ <span>5단계 도전</span>
+ <span>나만의 속도로</span>
+ </div>
+ </div>
+ <div className="sound-room">
+ <div className="room-top">
+ <span>
+ <i /> 에스의 사운드 룸
+ </span>
+ <span>{ENVIRONMENTS.length}가지 환경</span>
+ </div>
+ <div className="orbit orbit-one" />
+ <div className="orbit orbit-two" />
+ <span className="floating-note note-one" aria-hidden="true">
+ ♪
+ </span>
+ <span className="floating-note note-two" aria-hidden="true">
+ ✳
+ </span>
+ <div className="speech-bubble">행운의 단어를 찾아보거라!</div>
+ <Mascot />
+ <div className="room-bottom">
+ <SoundWave />
+ <span>소음 사이, 숨겨진 한마디</span>
+ </div>
+ </div>
+ </section>
+
+ <section className="setup-card" aria-labelledby="setup-title">
+ <div className="setup-heading">
+ <span className="section-number">01</span>
+ <div>
+ <h2 id="setup-title">나의 소리 탐험 준비</h2>
+ <p>편한 방식으로 시작하세요. 난이도는 조금씩 높아져요.</p>
+ </div>
+ <span className="round-badge">약 3–5분</span>
+ </div>
+ <div className="setup-grid">
+ <fieldset className="mode-field">
+ <legend>어떻게 답할까요?</legend>
+ <div className="mode-options">
+ {(
+ [
+ {
+ value: "mixed",
+ name: "골고루 풀기",
+ sub: "선택 + 직접 입력",
+ icon: "◈",
+ },
+ {
+ value: "choice",
+ name: "보기에서 선택",
+ sub: "4개 단어 중 하나",
+ icon: "☷",
+ },
+ {
+ value: "text",
+ name: "직접 입력",
+ sub: "기억한 단어 쓰기",
+ icon: "✎",
+ },
+ ] as const
+ ).map((option) => (
+ <label
+ className={`mode-option ${mode === option.value ? "selected" : ""}`}
+ key={option.value}
+ >
+ <input
+ type="radio"
+ aria-label={option.name}
+ name="mode"
+ value={option.value}
+ checked={mode === option.value}
+ onChange={() => setMode(option.value)}
+ />
+ <span className="mode-icon" aria-hidden="true">
+ {option.icon}
+ </span>
+ <strong>{option.name}</strong>
+ <small>{option.sub}</small>
+ </label>
+ ))}
+ </div>
+ </fieldset>
+ <div className="environment-field">
+ <label htmlFor="environment">배경 환경</label>
+ <select
+ id="environment"
+ value={environment}
+ onChange={(event) => setEnvironment(event.target.value)}
+ >
+ <option value="rotate">여덟 가지 일상 속으로</option>
+ {ENVIRONMENTS.map((e) => (
+ <option key={e.id} value={e.id}>
+ {e.name}
+ </option>
+ ))}
+ </select>
+ <p>
+ {ENVIRONMENTS.find((e) => e.id === environment)
+ ?.description ??
+ "길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요."}
+ </p>
+ <div className="environment-chips">
+ <span>길거리</span>
+ <span>카페</span>
+ <span>지하철</span>
+ <span>+5개 환경</span>
+ </div>
+ </div>
+ </div>
+ {volumeControl}
+ <div className="start-row">
+ <p>
+ <span aria-hidden="true">♬</span> 편안한 작은 음량으로 시작해
+ 보세요.
+ </p>
+ <button ref={startButton} className="primary" onClick={begin}>
+ 게임 시작 · 소리 듣기 <span aria-hidden="true">→</span>
+ </button>
+ </div>
+ </section>
+
+ <section id="howto" className="howto" aria-labelledby="howto-title">
+ <h2 id="howto-title">작은 단어를 발견하는 방법</h2>
+ <div className="steps">
+ <article>
+ <span>01</span>
+ <h3>먼저, 귀 기울이기</h3>
+ <p>
+ 에스의 안내와 소음 속 문장을 들어요.
+ <br />
+ 처음에는 또렷하게, 점점 더 작게.
+ </p>
+ </article>
+ <article>
+ <span>02</span>
+ <h3>기억한 단어 찾기</h3>
+ <p>
+ 소리가 끝나면 질문이 나타나요.
+ <br />
+ 보기에서 고르거나 직접 입력해요.
+ </p>
+ </article>
+ <article>
+ <span>03</span>
+ <h3>나의 발견 돌아보기</h3>
+ <p>
+ 열 문제 뒤 나의 결과를 확인해요.
+ <br />
+ 놓쳤다면 언제든 다시 들어도 좋아요.
+ </p>
+ </article>
+ </div>
+ </section>
+ </>
+ ) : game.phase === "results" && result ? (
+ <section className="results-panel panel">
+ <span className="eyebrow">열 번의 귀 기울임, 하나의 발견</span>
+ <div className="result-mascot">
+ <Mascot small />
+ </div>
+ <h2 ref={focusTarget} tabIndex={-1}>
+ 오늘의 소리 탐험을 마쳤어요.
+ </h2>
+ <p className="muted">
+ 작은 소리에 집중한 당신, 다음 탐험에서도 만나요.
+ </p>
+ <div className="stats">
+ <div>
+ <strong>{result.score.toLocaleString()}</strong>
+ <span>총 점수</span>
+ </div>
+ <div>
+ <strong>{result.accuracy}%</strong>
+ <span>정답률 · {result.correct}/10</span>
+ </div>
+ <div>
+ <strong>
+ {game.bestStreak}
+ <small>회</small>
+ </strong>
+ <span>최고 연속 정답</span>
+ </div>
+ </div>
+ <table aria-label="난이도별 결과">
+ <thead>
+ <tr>
+ <th scope="col">난이도</th>
+ <th scope="col">SNR</th>
+ <th scope="col">맞힌 문제</th>
+ </tr>
+ </thead>
+ <tbody>
+ {result.byLevel.map((row) => (
+ <tr key={row.level}>
+ <th scope="row">{row.level}단계</th>
+ <td>
+ {formatSnr(LEVELS[row.level - 1]!.db)}
+ </td>
+ <td>
+ {row.correct} / {row.total}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ <p className="muted">단계 × 100점 + 연속 정답 보너스 (최대 40점)</p>
+ <button className="primary" onClick={home}>
+ 다시 시작
+ </button>
+ </section>
+ ) : question && result ? (
+ <section className="game-layout" aria-label="단어 찾기 게임">
+ <div className="game-top">
+ <span className="eyebrow">나의 소리 탐험</span>
+ <span>
+ 문제 <b>{String(game.index + 1).padStart(2, "0")}</b> / 10
+ </span>
+ </div>
+ <progress
+ max={10}
+ value={game.answers.length}
+ aria-label="전체 진행률"
+ />
+ <div className="game-columns">
+ <aside className="environment-panel">
+ <p className="eyebrow">지금 듣고 있는 공간</p>
+ <h2>{question.environment.name}</h2>
+ <p>{question.environment.description}</p>
+ <Mascot small />
+ <SoundWave active={busy} />
+ <div className="level-tag">
+ {question.level}단계 · {formatSnr(question.db)}
+ </div>
+ <p className="db-note">
+ {describeSnr(question.db)}
+ <br />
+ {LEVELS[question.level - 1]!.name}
+ </p>
+ {volumeControl}
+ <div className="mini-stats">
+ <span>
+ 점수 <b>{result.score}</b>
+ </span>
+ <span>
+ 연속 정답 <b>{game.streak}</b>
+ </span>
+ </div>
+ </aside>
+ <div className="question-panel panel">
+ <div className="question-meta">
+ <span>{question.pattern.title}</span>
+ <span>
+ {question.mode === "choice" ? "객관식" : "주관식"}
+ </span>
+ </div>
+ {game.phase === "answer" ? (
+ <>
+ <h2 ref={focusTarget} tabIndex={-1}>
+ {question.pattern.question}
+ </h2>
+ <p className="muted">
+ {question.mode === "choice"
+ ? "가장 또렷하게 기억나는 단어를 골라 주세요."
+ : "기억나는 단어 하나를 입력해 주세요."}
+ </p>
+ {question.mode === "choice" ? (
+ <div
+ role="group"
+ aria-label="답 선택"
+ className="answers"
+ >
+ {question.options.map((option, i) => (
+ <button
+ key={option}
+ onClick={() =>
+ dispatch({ type: "ANSWER", input: option })
+ }
+ >
+ <span aria-hidden="true">{i + 1}</span>
+ {option}
+ </button>
+ ))}
+ </div>
+ ) : (
+ <form
+ onSubmit={(event) => {
+ event.preventDefault();
+ if (composing.current) return;
+ dispatch({ type: "ANSWER", input });
+ }}
+ >
+ <label htmlFor="answer">들은 단어</label>
+ <input
+ id="answer"
+ onCompositionStart={() => {
+ composing.current = true;
+ }}
+ onCompositionEnd={() => {
+ composing.current = false;
+ }}
+ onKeyDown={(event) => {
+ if (
+ event.key === "Enter" &&
+ (event.nativeEvent.isComposing ||
+ event.nativeEvent.keyCode === 229)
+ )
+ event.preventDefault();
+ }}
+ autoComplete="off"
+ maxLength={60}
+ value={input}
+ onChange={(event) => setInput(event.target.value)}
+ placeholder="여기에 단어를 적어 주세요"
+ />
+ <button
+ className="primary"
+ disabled={!normalizeAnswer(input)}
+ >
+ 정답 확인
+ </button>
+ </form>
+ )}
+ </>
+ ) : game.phase === "feedback" ? (
+ <div
+ className={`feedback ${game.answers.at(-1)!.correct ? "correct" : "incorrect"}`}
+ >
+ <span className="feedback-icon" aria-hidden="true">
+ {game.answers.at(-1)!.correct ? "✓" : "↻"}
+ </span>
+ <h2 ref={focusTarget} tabIndex={-1}>
+ {game.answers.at(-1)!.correct
+ ? "정답이에요!"
+ : "다음엔 찾을 수 있어요."}
+ </h2>
+ <p>
+ 숨겨진 단어는 <strong>{question.word.answer}</strong>
+ </p>
+ <blockquote>{question.sentence}</blockquote>
+ <p className="muted">
+ 내 답: {game.answers.at(-1)!.input} · +
+ {game.answers.at(-1)!.points}점
+ </p>
+ <button className="primary" onClick={next}>
+ {game.index === 9 ? "결과 보기" : "다음 문제"}{" "}
+ <span aria-hidden="true">→</span>
+ </button>
+ </div>
+ ) : (
+ <div className="listening">
+ <span className="listening-icon" aria-hidden="true">
+ ♬
+ </span>
+ <h2 ref={focusTarget} tabIndex={-1}>
+ {busy
+ ? "소리 사이에 집중해 보세요."
+ : "다음 단어를 만나 볼까요?"}
+ </h2>
+ <p>
+ 문장이 끝나면 질문이 나타나요.
+ <br />
+ 작은 단어 하나를 기억해 주세요.
+ </p>
+ <p role="status">
+ {game.phase === "loading"
+ ? "소리를 준비하고 있어요…"
+ : game.phase === "playing"
+ ? "에스의 목소리를 듣고 있어요…"
+ : "준비되면 문장 듣기를 눌러 주세요."}
+ </p>
+ </div>
+ )}
+ {game.error && (
+ <div className="error-box" role="alert">
+ <p>{game.error}</p>
+ <button onClick={() => void run(question)}>
+ 다시 시도
+ </button>
+ </div>
+ )}
+ {game.phase !== "feedback" && !game.error && (
+ <div className="play-controls">
+ <button
+ className="secondary"
+ disabled={busy}
+ onClick={() => void run(question)}
+ >
+ {busy
+ ? "듣는 중…"
+ : game.phase === "answer"
+ ? "↻ 다시 듣기"
+ : "▶ 문장 듣기"}
+ </button>
+ {busy && (
+ <button
+ className="text-button"
+ onClick={() => engine.stop()}
+ >
+ 듣기 중단
+ </button>
+ )}
+ <small>다시 들어도 점수는 줄어들지 않아요.</small>
+ </div>
+ )}
+ </div>
+ </div>
+ </section>
+ ) : null}
+ {game?.phase === "results" && volumeControl}
+ </main>
+ <footer className="footer wrap">
+ <p>Designed by OZ at Heat-T Medical Inc.</p>
+ <p>청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다.</p>
+ <span>개인정보 수집 없이, 오직 플레이만.</span>
+ </footer>
+ </>
+ );
+ }
+
❯ tests/neural-release.test.ts:13:51
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
| ^
14| }
15| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/21]⎯
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: index.html: expected '<!doctype html>\n<!-- 문서 언어를 한국어로 명시하…' to contain '0.1.1'
- Expected
+ Received
- 0.1.1
+ <!doctype html>
+ <!-- 문서 언어를 한국어로 명시하여 보조 기기의 발음과 줄바꿈 기준을 맞춘다.
+ 외부 폰트·CDN을 사용하지 않으며 모든 실행 자산은 같은 출처에서 제공한다. -->
+ <html lang="ko">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <meta name="theme-color" content="#f8f9f4" />
+ <meta
+ name="description"
+ content="소음 속 작은 한국어 단어를 찾는 청각적 주의·식별 게임. 에스와 함께 나만의 속도로 소리를 탐험하세요."
+ />
+ <title>단어를 맞춰라 · Find-Word v0.1</title>
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module" src="/src/main.tsx"></script>
+ </body>
+ </html>
+
❯ tests/neural-release.test.ts:13:51
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
| ^
14| }
15| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/21]⎯
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' to contain '0.1.1'
- Expected
+ Received
- 0.1.1
+ # 단어를 맞춰라 · Find-Word v0.1
+
+ 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다.
+
+ **Designed by OZ at Heat-T Medical Inc.**
+
+ Canonical repository: [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word).
+ 프로젝트 자체는 **private / all-rights-reserved**이며 `package.json`의
+ `license`는 `UNLICENSED`입니다. [LICENSE](LICENSE)를 참고하세요.
+ 런타임 의존성 React, ReactDOM, scheduler의 MIT 전문은
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 보존하며,
+ `public/THIRD_PARTY_NOTICES.md`가 빌드 시 `dist/THIRD_PARTY_NOTICES.md`로 복사됩니다.
+ 빌드 검증기는 설치된 의존성의 라이선스 전문·버전과 세 사본을 대조합니다.
+
+ React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장,
+ 분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며
+ 의료적 진단·치료나 효과를 주장하지 않습니다.
+
+ ## 실행
+
+ Node.js 최소 버전은 **22.22.2** (`>=22.22.2`)입니다. 잠긴 jsdom 계열 의존성이
+ 요구하는 지원 범위는 22.x의 22.22.2 이상, 24.x의 24.15.0 이상 또는 26.0.0 이상입니다.
+ 이 중 지원되는 LTS 버전을 사용하세요. `package-lock.json`을
+ 포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다.
+
+ ```bash
+ npm ci
+ npm run dev
+ ```
+
+ 표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를
+ 누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다.
+
+ ## 플레이
+
+ - 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다.
+ - 8개 환경을 순환하거나 하나를 지정합니다.
+ - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다.
+ - 10문제 동안 2문제마다 난이도가 올라갑니다: **SNR +6, +3, 0, −3, −6 dB**.
+ 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다.
+ - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답은 최대 40점의 보너스를 받습니다.
+ - 결과에서 점수·정답률·최고 연속 정답·단계별 결과를 확인하고 다시 시작할 수 있습니다.
+ - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다.
+
+ 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과
+ 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은
+ [WORDS.md](docs/WORDS.md)에 설명했습니다.
+ 청취 과제는 직접 목표 식별, 연습 암호와 진짜 암호 구별, 앞 단어를 잊고 다음
+ 단어 기억, 첫째·마지막·가운데 선택, 반복 찾기, 정정 반영, 신호 조건 적용,
+ 오른쪽 표지 선택의 10가지입니다. 문장에 실제 보기의 오답도 포함됩니다.
+
+ ## 검증과 정적 빌드
+
+ ```bash
+ npm run lint
+ npm test
+ npm run build
+ npm run preview
+ ```
+
+ `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기
+ 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개가 원본 해시 그대로
+ 포함되었는지 확인합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다.
+ 런타임 MIT notice가 `dist/`에 전문 그대로 포함되는지도 검증합니다.
+
+ 실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증:
+
+ ```bash
+ # Chromium이 없는 개발 환경에서 한 번만 실행합니다.
+ npx playwright install chromium
+ npm run test:browser
+ # 이미 설치된 Chrome을 사용할 때의 대안입니다.
+ CHROME_PATH=/usr/bin/google-chrome npm run test:browser
+ ```
+
+ Chromium sandbox는 기본으로 켭니다. sandbox를 지원하지 않는 격리된 개발
+ 환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`을 명시해 끌 수 있습니다. `0`, `false`,
+ 빈 값이나 미설정은 sandbox를 유지합니다. `CHROME_PATH` 지정은 sandbox를 끄지 않습니다.
+
+ 브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요.
+ 실제 음성 재생이 끝나기를 기다려 약 2–3분이 걸립니다. 로컬 4173 포트를
+ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다.
+
+ ## 음성과 문서
+
+ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은
+ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다.
+ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
+ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
+
+ - [설계와 상태 전이](docs/ARCHITECTURE.md)
+ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md)
+ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md)
+
+ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다.
+
❯ tests/neural-release.test.ts:13:51
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
| ^
14| }
15| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/21]⎯
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: LICENSE: expected 'Find-Word v0.1\nPrivate / proprietary…' to contain '0.1.1'
- Expected
+ Received
- 0.1.1
+ Find-Word v0.1
+ Private / proprietary. All rights reserved.
+
+ No license is granted to use, copy, modify, publish, distribute, sublicense,
+ or sell the original project code, documentation, or assets without prior
+ written permission from the respective rights holders.
+
+ Third-party components remain subject to their own licenses. Their notices
+ are reproduced in THIRD_PARTY_NOTICES.md and accompany the distribution.
+
❯ tests/neural-release.test.ts:13:51
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
| ^
14| }
15| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/21]⎯
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain '0.1.1'
- Expected
+ Received
- 0.1.1
+ # 오디오 출처와 재현
+
+ ## 포함 자산
+
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
+ | --- | --- | --- | --- |
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
+
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
+
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
+
+ ## 사용 도구와 라이선스
+
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
+
+ ## 생성 절차
+
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
+
+ ```bash
+ npm run audio:generate
+ npm test -- tests/assets.test.ts
+ npm run build
+ ```
+
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
+
+ ```bash
+ mkdir -p .cache/espeak
+ cd .cache/espeak
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
+ cd ../..
+ npm run audio:generate
+ ```
+
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
+
+ ## 음량·품질 한계
+
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
+
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
+
❯ tests/neural-release.test.ts:13:51
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
| ^
14| }
15| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: expected 'python3 scripts/generate_audio.py' to be '.venv-audio/bin/python scripts/genera…' // Object.is equality
Expected: ".venv-audio/bin/python scripts/generate_audio.py"
Received: "python3 scripts/generate_audio.py"
❯ tests/neural-release.test.ts:19:46
17| it("고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다", () => {
18| const pkg = JSON.parse(readFileSync("package.json", "utf8"));
19| expect.soft(pkg.scripts["audio:generate"]).toBe(".venv-audio/bin/pyt…
| ^
20| for (const path of ["README.md", "docs/AUDIO_PROVENANCE.md"]) {
21| const doc = readFileSync(path, "utf8");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' to contain 'requirements-audio.txt'
- Expected
+ Received
- requirements-audio.txt
+ # 단어를 맞춰라 · Find-Word v0.1
+
+ 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다.
+
+ **Designed by OZ at Heat-T Medical Inc.**
+
+ Canonical repository: [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word).
+ 프로젝트 자체는 **private / all-rights-reserved**이며 `package.json`의
+ `license`는 `UNLICENSED`입니다. [LICENSE](LICENSE)를 참고하세요.
+ 런타임 의존성 React, ReactDOM, scheduler의 MIT 전문은
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 보존하며,
+ `public/THIRD_PARTY_NOTICES.md`가 빌드 시 `dist/THIRD_PARTY_NOTICES.md`로 복사됩니다.
+ 빌드 검증기는 설치된 의존성의 라이선스 전문·버전과 세 사본을 대조합니다.
+
+ React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장,
+ 분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며
+ 의료적 진단·치료나 효과를 주장하지 않습니다.
+
+ ## 실행
+
+ Node.js 최소 버전은 **22.22.2** (`>=22.22.2`)입니다. 잠긴 jsdom 계열 의존성이
+ 요구하는 지원 범위는 22.x의 22.22.2 이상, 24.x의 24.15.0 이상 또는 26.0.0 이상입니다.
+ 이 중 지원되는 LTS 버전을 사용하세요. `package-lock.json`을
+ 포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다.
+
+ ```bash
+ npm ci
+ npm run dev
+ ```
+
+ 표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를
+ 누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다.
+
+ ## 플레이
+
+ - 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다.
+ - 8개 환경을 순환하거나 하나를 지정합니다.
+ - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다.
+ - 10문제 동안 2문제마다 난이도가 올라갑니다: **SNR +6, +3, 0, −3, −6 dB**.
+ 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다.
+ - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답은 최대 40점의 보너스를 받습니다.
+ - 결과에서 점수·정답률·최고 연속 정답·단계별 결과를 확인하고 다시 시작할 수 있습니다.
+ - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다.
+
+ 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과
+ 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은
+ [WORDS.md](docs/WORDS.md)에 설명했습니다.
+ 청취 과제는 직접 목표 식별, 연습 암호와 진짜 암호 구별, 앞 단어를 잊고 다음
+ 단어 기억, 첫째·마지막·가운데 선택, 반복 찾기, 정정 반영, 신호 조건 적용,
+ 오른쪽 표지 선택의 10가지입니다. 문장에 실제 보기의 오답도 포함됩니다.
+
+ ## 검증과 정적 빌드
+
+ ```bash
+ npm run lint
+ npm test
+ npm run build
+ npm run preview
+ ```
+
+ `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기
+ 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개가 원본 해시 그대로
+ 포함되었는지 확인합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다.
+ 런타임 MIT notice가 `dist/`에 전문 그대로 포함되는지도 검증합니다.
+
+ 실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증:
+
+ ```bash
+ # Chromium이 없는 개발 환경에서 한 번만 실행합니다.
+ npx playwright install chromium
+ npm run test:browser
+ # 이미 설치된 Chrome을 사용할 때의 대안입니다.
+ CHROME_PATH=/usr/bin/google-chrome npm run test:browser
+ ```
+
+ Chromium sandbox는 기본으로 켭니다. sandbox를 지원하지 않는 격리된 개발
+ 환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`을 명시해 끌 수 있습니다. `0`, `false`,
+ 빈 값이나 미설정은 sandbox를 유지합니다. `CHROME_PATH` 지정은 sandbox를 끄지 않습니다.
+
+ 브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요.
+ 실제 음성 재생이 끝나기를 기다려 약 2–3분이 걸립니다. 로컬 4173 포트를
+ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다.
+
+ ## 음성과 문서
+
+ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은
+ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다.
+ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
+ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
+
+ - [설계와 상태 전이](docs/ARCHITECTURE.md)
+ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md)
+ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md)
+
+ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다.
+
❯ tests/neural-release.test.ts:22:28
20| for (const path of ["README.md", "docs/AUDIO_PROVENANCE.md"]) {
21| const doc = readFileSync(path, "utf8");
22| expect.soft(doc, path).toContain("requirements-audio.txt");
| ^
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
24| expect.soft(doc, path).toContain("7.3.1");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' to contain 'ko-KR-HyunsuMultilingualNeural'
- Expected
+ Received
- ko-KR-HyunsuMultilingualNeural
+ # 단어를 맞춰라 · Find-Word v0.1
+
+ 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다.
+
+ **Designed by OZ at Heat-T Medical Inc.**
+
+ Canonical repository: [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word).
+ 프로젝트 자체는 **private / all-rights-reserved**이며 `package.json`의
+ `license`는 `UNLICENSED`입니다. [LICENSE](LICENSE)를 참고하세요.
+ 런타임 의존성 React, ReactDOM, scheduler의 MIT 전문은
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 보존하며,
+ `public/THIRD_PARTY_NOTICES.md`가 빌드 시 `dist/THIRD_PARTY_NOTICES.md`로 복사됩니다.
+ 빌드 검증기는 설치된 의존성의 라이선스 전문·버전과 세 사본을 대조합니다.
+
+ React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장,
+ 분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며
+ 의료적 진단·치료나 효과를 주장하지 않습니다.
+
+ ## 실행
+
+ Node.js 최소 버전은 **22.22.2** (`>=22.22.2`)입니다. 잠긴 jsdom 계열 의존성이
+ 요구하는 지원 범위는 22.x의 22.22.2 이상, 24.x의 24.15.0 이상 또는 26.0.0 이상입니다.
+ 이 중 지원되는 LTS 버전을 사용하세요. `package-lock.json`을
+ 포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다.
+
+ ```bash
+ npm ci
+ npm run dev
+ ```
+
+ 표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를
+ 누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다.
+
+ ## 플레이
+
+ - 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다.
+ - 8개 환경을 순환하거나 하나를 지정합니다.
+ - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다.
+ - 10문제 동안 2문제마다 난이도가 올라갑니다: **SNR +6, +3, 0, −3, −6 dB**.
+ 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다.
+ - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답은 최대 40점의 보너스를 받습니다.
+ - 결과에서 점수·정답률·최고 연속 정답·단계별 결과를 확인하고 다시 시작할 수 있습니다.
+ - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다.
+
+ 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과
+ 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은
+ [WORDS.md](docs/WORDS.md)에 설명했습니다.
+ 청취 과제는 직접 목표 식별, 연습 암호와 진짜 암호 구별, 앞 단어를 잊고 다음
+ 단어 기억, 첫째·마지막·가운데 선택, 반복 찾기, 정정 반영, 신호 조건 적용,
+ 오른쪽 표지 선택의 10가지입니다. 문장에 실제 보기의 오답도 포함됩니다.
+
+ ## 검증과 정적 빌드
+
+ ```bash
+ npm run lint
+ npm test
+ npm run build
+ npm run preview
+ ```
+
+ `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기
+ 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개가 원본 해시 그대로
+ 포함되었는지 확인합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다.
+ 런타임 MIT notice가 `dist/`에 전문 그대로 포함되는지도 검증합니다.
+
+ 실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증:
+
+ ```bash
+ # Chromium이 없는 개발 환경에서 한 번만 실행합니다.
+ npx playwright install chromium
+ npm run test:browser
+ # 이미 설치된 Chrome을 사용할 때의 대안입니다.
+ CHROME_PATH=/usr/bin/google-chrome npm run test:browser
+ ```
+
+ Chromium sandbox는 기본으로 켭니다. sandbox를 지원하지 않는 격리된 개발
+ 환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`을 명시해 끌 수 있습니다. `0`, `false`,
+ 빈 값이나 미설정은 sandbox를 유지합니다. `CHROME_PATH` 지정은 sandbox를 끄지 않습니다.
+
+ 브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요.
+ 실제 음성 재생이 끝나기를 기다려 약 2–3분이 걸립니다. 로컬 4173 포트를
+ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다.
+
+ ## 음성과 문서
+
+ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은
+ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다.
+ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
+ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
+
+ - [설계와 상태 전이](docs/ARCHITECTURE.md)
+ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md)
+ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md)
+
+ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다.
+
❯ tests/neural-release.test.ts:23:28
21| const doc = readFileSync(path, "utf8");
22| expect.soft(doc, path).toContain("requirements-audio.txt");
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
| ^
24| expect.soft(doc, path).toContain("7.3.1");
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' to contain '7.3.1'
- Expected
+ Received
- 7.3.1
+ # 단어를 맞춰라 · Find-Word v0.1
+
+ 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다.
+
+ **Designed by OZ at Heat-T Medical Inc.**
+
+ Canonical repository: [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word).
+ 프로젝트 자체는 **private / all-rights-reserved**이며 `package.json`의
+ `license`는 `UNLICENSED`입니다. [LICENSE](LICENSE)를 참고하세요.
+ 런타임 의존성 React, ReactDOM, scheduler의 MIT 전문은
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 보존하며,
+ `public/THIRD_PARTY_NOTICES.md`가 빌드 시 `dist/THIRD_PARTY_NOTICES.md`로 복사됩니다.
+ 빌드 검증기는 설치된 의존성의 라이선스 전문·버전과 세 사본을 대조합니다.
+
+ React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장,
+ 분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며
+ 의료적 진단·치료나 효과를 주장하지 않습니다.
+
+ ## 실행
+
+ Node.js 최소 버전은 **22.22.2** (`>=22.22.2`)입니다. 잠긴 jsdom 계열 의존성이
+ 요구하는 지원 범위는 22.x의 22.22.2 이상, 24.x의 24.15.0 이상 또는 26.0.0 이상입니다.
+ 이 중 지원되는 LTS 버전을 사용하세요. `package-lock.json`을
+ 포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다.
+
+ ```bash
+ npm ci
+ npm run dev
+ ```
+
+ 표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를
+ 누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다.
+
+ ## 플레이
+
+ - 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다.
+ - 8개 환경을 순환하거나 하나를 지정합니다.
+ - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다.
+ - 10문제 동안 2문제마다 난이도가 올라갑니다: **SNR +6, +3, 0, −3, −6 dB**.
+ 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다.
+ - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답은 최대 40점의 보너스를 받습니다.
+ - 결과에서 점수·정답률·최고 연속 정답·단계별 결과를 확인하고 다시 시작할 수 있습니다.
+ - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다.
+
+ 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과
+ 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은
+ [WORDS.md](docs/WORDS.md)에 설명했습니다.
+ 청취 과제는 직접 목표 식별, 연습 암호와 진짜 암호 구별, 앞 단어를 잊고 다음
+ 단어 기억, 첫째·마지막·가운데 선택, 반복 찾기, 정정 반영, 신호 조건 적용,
+ 오른쪽 표지 선택의 10가지입니다. 문장에 실제 보기의 오답도 포함됩니다.
+
+ ## 검증과 정적 빌드
+
+ ```bash
+ npm run lint
+ npm test
+ npm run build
+ npm run preview
+ ```
+
+ `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기
+ 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개가 원본 해시 그대로
+ 포함되었는지 확인합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다.
+ 런타임 MIT notice가 `dist/`에 전문 그대로 포함되는지도 검증합니다.
+
+ 실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증:
+
+ ```bash
+ # Chromium이 없는 개발 환경에서 한 번만 실행합니다.
+ npx playwright install chromium
+ npm run test:browser
+ # 이미 설치된 Chrome을 사용할 때의 대안입니다.
+ CHROME_PATH=/usr/bin/google-chrome npm run test:browser
+ ```
+
+ Chromium sandbox는 기본으로 켭니다. sandbox를 지원하지 않는 격리된 개발
+ 환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`을 명시해 끌 수 있습니다. `0`, `false`,
+ 빈 값이나 미설정은 sandbox를 유지합니다. `CHROME_PATH` 지정은 sandbox를 끄지 않습니다.
+
+ 브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요.
+ 실제 음성 재생이 끝나기를 기다려 약 2–3분이 걸립니다. 로컬 4173 포트를
+ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다.
+
+ ## 음성과 문서
+
+ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은
+ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다.
+ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
+ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
+
+ - [설계와 상태 전이](docs/ARCHITECTURE.md)
+ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md)
+ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md)
+
+ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다.
+
❯ tests/neural-release.test.ts:24:28
22| expect.soft(doc, path).toContain("requirements-audio.txt");
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
24| expect.soft(doc, path).toContain("7.3.1");
| ^
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
26| }
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[13/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' not to match /espeak|포먼트/i
- Expected:
/espeak|포먼트/i
+ Received:
"# 단어를 맞춰라 · Find-Word v0.1
소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다.
**Designed by OZ at Heat-T Medical Inc.**
Canonical repository: [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word).
프로젝트 자체는 **private / all-rights-reserved**이며 `package.json`의
`license`는 `UNLICENSED`입니다. [LICENSE](LICENSE)를 참고하세요.
런타임 의존성 React, ReactDOM, scheduler의 MIT 전문은
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 보존하며,
`public/THIRD_PARTY_NOTICES.md`가 빌드 시 `dist/THIRD_PARTY_NOTICES.md`로 복사됩니다.
빌드 검증기는 설치된 의존성의 라이선스 전문·버전과 세 사본을 대조합니다.
React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장,
분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며
의료적 진단·치료나 효과를 주장하지 않습니다.
## 실행
Node.js 최소 버전은 **22.22.2** (`>=22.22.2`)입니다. 잠긴 jsdom 계열 의존성이
요구하는 지원 범위는 22.x의 22.22.2 이상, 24.x의 24.15.0 이상 또는 26.0.0 이상입니다.
이 중 지원되는 LTS 버전을 사용하세요. `package-lock.json`을
포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다.
```bash
npm ci
npm run dev
```
표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를
누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다.
## 플레이
- 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다.
- 8개 환경을 순환하거나 하나를 지정합니다.
- 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다.
- 10문제 동안 2문제마다 난이도가 올라갑니다: **SNR +6, +3, 0, −3, −6 dB**.
양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다.
- 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답은 최대 40점의 보너스를 받습니다.
- 결과에서 점수·정답률·최고 연속 정답·단계별 결과를 확인하고 다시 시작할 수 있습니다.
- 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다.
10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과
단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은
[WORDS.md](docs/WORDS.md)에 설명했습니다.
청취 과제는 직접 목표 식별, 연습 암호와 진짜 암호 구별, 앞 단어를 잊고 다음
단어 기억, 첫째·마지막·가운데 선택, 반복 찾기, 정정 반영, 신호 조건 적용,
오른쪽 표지 선택의 10가지입니다. 문장에 실제 보기의 오답도 포함됩니다.
## 검증과 정적 빌드
```bash
npm run lint
npm test
npm run build
npm run preview
```
`lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기
테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개가 원본 해시 그대로
포함되었는지 확인합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다.
런타임 MIT notice가 `dist/`에 전문 그대로 포함되는지도 검증합니다.
실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증:
```bash
# Chromium이 없는 개발 환경에서 한 번만 실행합니다.
npx playwright install chromium
npm run test:browser
# 이미 설치된 Chrome을 사용할 때의 대안입니다.
CHROME_PATH=/usr/bin/google-chrome npm run test:browser
```
Chromium sandbox는 기본으로 켭니다. sandbox를 지원하지 않는 격리된 개발
환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`을 명시해 끌 수 있습니다. `0`, `false`,
빈 값이나 미설정은 sandbox를 유지합니다. `CHROME_PATH` 지정은 sandbox를 끄지 않습니다.
브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요.
실제 음성 재생이 끝나기를 기다려 약 2–3분이 걸립니다. 로컬 4173 포트를
테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다.
## 음성과 문서
한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은
브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다.
생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
- [설계와 상태 전이](docs/ARCHITECTURE.md)
- [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md)
- [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md)
이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다.
"
❯ tests/neural-release.test.ts:25:32
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
24| expect.soft(doc, path).toContain("7.3.1");
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
| ^
26| }
27| const provenance = readFileSync("docs/AUDIO_PROVENANCE.md", "utf8");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain 'requirements-audio.txt'
- Expected
+ Received
- requirements-audio.txt
+ # 오디오 출처와 재현
+
+ ## 포함 자산
+
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
+ | --- | --- | --- | --- |
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
+
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
+
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
+
+ ## 사용 도구와 라이선스
+
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
+
+ ## 생성 절차
+
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
+
+ ```bash
+ npm run audio:generate
+ npm test -- tests/assets.test.ts
+ npm run build
+ ```
+
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
+
+ ```bash
+ mkdir -p .cache/espeak
+ cd .cache/espeak
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
+ cd ../..
+ npm run audio:generate
+ ```
+
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
+
+ ## 음량·품질 한계
+
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
+
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
+
❯ tests/neural-release.test.ts:22:28
20| for (const path of ["README.md", "docs/AUDIO_PROVENANCE.md"]) {
21| const doc = readFileSync(path, "utf8");
22| expect.soft(doc, path).toContain("requirements-audio.txt");
| ^
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
24| expect.soft(doc, path).toContain("7.3.1");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain 'ko-KR-HyunsuMultilingualNeural'
- Expected
+ Received
- ko-KR-HyunsuMultilingualNeural
+ # 오디오 출처와 재현
+
+ ## 포함 자산
+
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
+ | --- | --- | --- | --- |
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
+
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
+
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
+
+ ## 사용 도구와 라이선스
+
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
+
+ ## 생성 절차
+
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
+
+ ```bash
+ npm run audio:generate
+ npm test -- tests/assets.test.ts
+ npm run build
+ ```
+
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
+
+ ```bash
+ mkdir -p .cache/espeak
+ cd .cache/espeak
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
+ cd ../..
+ npm run audio:generate
+ ```
+
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
+
+ ## 음량·품질 한계
+
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
+
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
+
❯ tests/neural-release.test.ts:23:28
21| const doc = readFileSync(path, "utf8");
22| expect.soft(doc, path).toContain("requirements-audio.txt");
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
| ^
24| expect.soft(doc, path).toContain("7.3.1");
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain '7.3.1'
- Expected
+ Received
- 7.3.1
+ # 오디오 출처와 재현
+
+ ## 포함 자산
+
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
+ | --- | --- | --- | --- |
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
+
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
+
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
+
+ ## 사용 도구와 라이선스
+
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
+
+ ## 생성 절차
+
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
+
+ ```bash
+ npm run audio:generate
+ npm test -- tests/assets.test.ts
+ npm run build
+ ```
+
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
+
+ ```bash
+ mkdir -p .cache/espeak
+ cd .cache/espeak
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
+ cd ../..
+ npm run audio:generate
+ ```
+
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
+
+ ## 음량·품질 한계
+
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
+
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
+
❯ tests/neural-release.test.ts:24:28
22| expect.soft(doc, path).toContain("requirements-audio.txt");
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
24| expect.soft(doc, path).toContain("7.3.1");
| ^
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
26| }
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[17/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' not to match /espeak|포먼트/i
- Expected:
/espeak|포먼트/i
+ Received:
"# 오디오 출처와 재현
## 포함 자산
| 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
| --- | --- | --- | --- |
| `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
| 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
| 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
| 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
**20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
`public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
## 사용 도구와 라이선스
- [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
[공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
- [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
- 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
[GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
[GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
- 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
[루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
## 생성 절차
일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
```bash
npm run audio:generate
npm test -- tests/assets.test.ts
npm run build
```
현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
```bash
mkdir -p .cache/espeak
cd .cache/espeak
apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
for pkg in *.deb; do dpkg-deb -x \"$pkg\" root; done
cd ../..
npm run audio:generate
```
`scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
## 음량·품질 한계
보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
"
❯ tests/neural-release.test.ts:25:32
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
24| expect.soft(doc, path).toContain("7.3.1");
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
| ^
26| }
27| const provenance = readFileSync("docs/AUDIO_PROVENANCE.md", "utf8");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[18/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain 'https://www.microsoft.com/en-us/servi…'
- Expected
+ Received
- https://www.microsoft.com/en-us/servicesagreement
+ # 오디오 출처와 재현
+
+ ## 포함 자산
+
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
+ | --- | --- | --- | --- |
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
+
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
+
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
+
+ ## 사용 도구와 라이선스
+
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
+
+ ## 생성 절차
+
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
+
+ ```bash
+ npm run audio:generate
+ npm test -- tests/assets.test.ts
+ npm run build
+ ```
+
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
+
+ ```bash
+ mkdir -p .cache/espeak
+ cd .cache/espeak
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
+ cd ../..
+ npm run audio:generate
+ ```
+
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
+
+ ## 음량·품질 한계
+
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
+
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
+
❯ tests/neural-release.test.ts:28:27
26| }
27| const provenance = readFileSync("docs/AUDIO_PROVENANCE.md", "utf8");
28| expect.soft(provenance).toContain("https://www.microsoft.com/en-us/s…
| ^
29| expect.soft(provenance).toContain("LGPL");
30| expect.soft(provenance).toContain("독점 권리를 주장하지 않는다");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[19/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain '독점 권리를 주장하지 않는다'
- Expected
+ Received
- 독점 권리를 주장하지 않는다
+ # 오디오 출처와 재현
+
+ ## 포함 자산
+
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
+ | --- | --- | --- | --- |
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
+
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
+
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
+
+ ## 사용 도구와 라이선스
+
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
+
+ ## 생성 절차
+
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
+
+ ```bash
+ npm run audio:generate
+ npm test -- tests/assets.test.ts
+ npm run build
+ ```
+
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
+
+ ```bash
+ mkdir -p .cache/espeak
+ cd .cache/espeak
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
+ cd ../..
+ npm run audio:generate
+ ```
+
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
+
+ ## 음량·품질 한계
+
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
+
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
+
❯ tests/neural-release.test.ts:30:27
28| expect.soft(provenance).toContain("https://www.microsoft.com/en-us/s…
29| expect.soft(provenance).toContain("LGPL");
30| expect.soft(provenance).toContain("독점 권리를 주장하지 않는다");
| ^
31| expect.soft(readFileSync("LICENSE", "utf8")).toContain("does not cla…
32| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[20/21]⎯
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
AssertionError: expected 'Find-Word v0.1\nPrivate / proprietary…' to contain 'does not claim exclusive rights to ge…'
- Expected
+ Received
- does not claim exclusive rights to generated speech
+ Find-Word v0.1
+ Private / proprietary. All rights reserved.
+
+ No license is granted to use, copy, modify, publish, distribute, sublicense,
+ or sell the original project code, documentation, or assets without prior
+ written permission from the respective rights holders.
+
+ Third-party components remain subject to their own licenses. Their notices
+ are reproduced in THIRD_PARTY_NOTICES.md and accompany the distribution.
+
❯ tests/neural-release.test.ts:31:48
29| expect.soft(provenance).toContain("LGPL");
30| expect.soft(provenance).toContain("독점 권리를 주장하지 않는다");
31| expect.soft(readFileSync("LICENSE", "utf8")).toContain("does not cla…
| ^
32| });
33|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[21/21]⎯
Test Files 2 failed (2)
Tests 3 failed | 12 skipped (15)
Start at 06:47:57
Duration 1.28s (environment 48%, tests 22%, setup 17%, transform 10%, import 3%, worker 1%)