Files
2026-10-14 10:01:46 +00:00

2752 lines
121 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.
RUN v5.0.4 /home/hermes/worktrees/find-word-v0.2.3
❯ tests/assets.test.ts (2 tests | 1 failed) 336ms
❯ 저장한 한국어 neural 음성 자산 (2)
× 0.2.3 neural provider·voice·prosody와 실측 도구 버전을 기록한다 10ms
❯ tests/audio.test.ts (11 tests | 3 failed) 272ms
❯ 오디오 재생 수명과 믹서 (8)
× intro와 목표 실제 fetch URL에 같은 명시적 cache-busting 버전을 붙인다 52ms
× intro 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다 45ms
× target 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다 29ms
❯ tests/neural-release.test.ts (3 tests | 2 failed) 64ms
× 0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다 48ms
× 현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다 11ms
❯ tests/game.test.ts (16 tests | 1 failed) 50ms
❯ 출제와 정답 판정 (5)
× 목표 WAV 100개 모두 같은 명시적 cache-busting 버전과 정답 mapping을 유지한다 33ms
❯ tests/App.test.tsx (18 tests | 1 failed) 3592ms
❯ 시작부터 결과까지 사용자 흐름 (13)
× 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다 44ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 8 ⎯⎯⎯⎯⎯⎯⎯
FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다
TestingLibraryElementError: Unable to find an element with the text: v0.2.3. 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.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>
한 라운드 
3
개의 질문
</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...
❯ 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:206:19
204| }),
205| ).toBeInTheDocument();
206| expect(screen.getByText("v0.2.3")).toBeInTheDocument();
| ^
207| expect(f.audio.play).not.toHaveBeenCalled();
208| expect(
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/20]⎯
FAIL tests/assets.test.ts > 저장한 한국어 neural 음성 자산 > 0.2.3 neural provider·voice·prosody와 실측 도구 버전을 기록한다
AssertionError: expected '0.1.1' to be '0.2.3' // Object.is equality
Expected: "0.2.3"
Received: "0.1.1"
❯ tests/assets.test.ts:39:35
37| describe("저장한 한국어 neural 음성 자산", () => {
38| it("0.2.3 neural provider·voice·prosody와 실측 도구 버전을 기록한다", () => {
39| expect.soft(manifest.version).toBe("0.2.3");
| ^
40| expect.soft(manifest.provider).toBe("Microsoft Edge online TTS");
41| expect.soft(manifest.voice).toBe("ko-KR-HyunsuMultilingualNeural");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/20]⎯
FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > intro와 목표 실제 fetch URL에 같은 명시적 cache-busting 버전을 붙인다
AssertionError: expected [ '/audio/intro.wav?v=0.1.1', …(2) ] to deeply equal [ '/audio/intro.wav?v=0.2.3', …(2) ]
- Expected
+ Received
[
- "/audio/intro.wav?v=0.2.3",
- "/audio/correction-market.wav?v=0.2.3",
+ "/audio/intro.wav?v=0.1.1",
+ "/audio/correction-market.wav?v=0.1.1",
"/ambience/street.wav?v=0.1.1",
]
❯ tests/audio.test.ts:98:54
96| f.sources.at(-1)!.onended?.();
97| await done;
98| expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([
| ^
99| "/audio/intro.wav?v=0.2.3",
100| `/audio/${question.id}.wav?v=0.2.3`,
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/20]⎯
FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > intro 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다
FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > target 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다
AssertionError: expected [ '/audio/intro.wav?v=0.1.1', …(5) ] to deeply equal [ '/audio/intro.wav?v=0.2.3', …(5) ]
- Expected
+ Received
[
- "/audio/intro.wav?v=0.2.3",
- "/audio/correction-market.wav?v=0.2.3",
+ "/audio/intro.wav?v=0.1.1",
+ "/audio/correction-market.wav?v=0.1.1",
"/ambience/street.wav?v=0.1.1",
- "/audio/intro.wav?v=0.2.3",
- "/audio/correction-market.wav?v=0.2.3",
+ "/audio/intro.wav?v=0.1.1",
+ "/audio/correction-market.wav?v=0.1.1",
"/ambience/street.wav?v=0.1.1",
]
❯ tests/audio.test.ts:132:56
130| f.sources.at(-1)!.onended?.();
131| await done;
132| expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([
| ^
133| "/audio/intro.wav?v=0.2.3",
134| `/audio/${question.id}.wav?v=0.2.3`,
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/20]⎯
FAIL tests/game.test.ts > 출제와 정답 판정 > 목표 WAV 100개 모두 같은 명시적 cache-busting 버전과 정답 mapping을 유지한다
AssertionError: expected Set{ '0.1.1' } to deeply equal Set{ '0.2.3' }
- Expected
+ Received
Set {
- "0.2.3",
+ "0.1.1",
}
❯ tests/game.test.ts:32:22
30| versions.add(url.searchParams.get("v"));
31| }
32| expect(versions).toEqual(new Set(["0.2.3"]));
| ^
33| });
34| it("실제 보기에 있는 경쟁 단어를 문장에 넣고 청취 연산별 순서를 유지한다", () => {
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/20]⎯
FAIL tests/neural-release.test.ts > 0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: expected '0.1.1' to be '0.2.3' // Object.is equality
Expected: "0.2.3"
Received: "0.1.1"
❯ 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.2.3");
| ^
10| expect.soft(lock.version).toBe("0.2.3");
11| expect.soft(lock.packages[""].version).toBe("0.2.3");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/20]⎯
FAIL tests/neural-release.test.ts > 0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: expected '0.1.1' to be '0.2.3' // Object.is equality
Expected: "0.2.3"
Received: "0.1.1"
❯ tests/neural-release.test.ts:10:29
8| const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
9| expect.soft(pkg.version).toBe("0.2.3");
10| expect.soft(lock.version).toBe("0.2.3");
| ^
11| expect.soft(lock.packages[""].version).toBe("0.2.3");
12| for (const path of [
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/20]⎯
FAIL tests/neural-release.test.ts > 0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: expected '0.1.1' to be '0.2.3' // Object.is equality
Expected: "0.2.3"
Received: "0.1.1"
❯ tests/neural-release.test.ts:11:42
9| expect.soft(pkg.version).toBe("0.2.3");
10| expect.soft(lock.version).toBe("0.2.3");
11| expect.soft(lock.packages[""].version).toBe("0.2.3");
| ^
12| for (const path of [
13| "src/App.tsx",
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/20]⎯
FAIL tests/neural-release.test.ts > 0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: src/App.tsx: expected 'import { useEffect, useRef, useState …' to contain '0.2.3'
- Expected
+ Received
- 0.2.3
+ import { useEffect, useRef, useState } from "react";
+ import { AudioEngine, type AudioPort } from "./audio/engine";
+ import { ENVIRONMENTS, LEVELS } from "./content";
+ import {
+ createQuestions,
+ gameReducer,
+ initialGame,
+ normalizeAnswer,
+ normalizeSnr,
+ ROUND_SIZE,
+ SNR_MIN,
+ SNR_MAX,
+ 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");
+ // 시작 단계는 홈 설정, snrOverride는 사용자가 직접 바꾼 라운드 간 SNR이다.
+ // null이면 각 새 단계의 기본값을 사용하므로 자동 진행과 수동 선택을 구분한다.
+ const [startingLevel, setStartingLevel] = useState(1);
+ const [snrOverride, setSnrOverride] = useState<number | null>(null);
+ 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);
+ // 함수형 갱신으로 같은 클릭 안의 초기화와 PLAY도 순서대로 reducer에 전달한다.
+ 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]);
+
+ // token은 엔진 취소가 지연되는 장치에서도 오래된 콜백이 새 화면을 바꾸지 못하게 한다.
+ 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 = (level = startingLevel) => {
+ const questions = createQuestions(
+ (seed ?? Date.now()) + attempt,
+ mode,
+ environment,
+ level,
+ ).map(q => ({ ...q, db: snrOverride ?? q.db }));
+ setAttempt(a => a + 1);
+ introDone.current = false;
+ composing.current = false;
+ setInput("");
+ setGame(initialGame(questions));
+ // 사용자 클릭 호출 스택에서 엔진을 열어 자동 재생 정책을 준수한다.
+ void run(questions[0]!);
+ };
+ // 종료 결과를 읽은 뒤 홈으로 복귀할 때 수동 SNR만 지우고 명시적 홈 설정은 보존한다.
+ const home = () => {
+ token.current++;
+ playing.current = false;
+ engine.stop();
+ composing.current = false;
+ setGame(null);
+ setInput("");
+ setSnrOverride(null);
+ requestAnimationFrame(() => startButton.current?.focus());
+ };
+ // 종료를 먼저 무효화한 뒤 엔진을 정지해야 동기/비동기 완료 경합도 차단된다.
+ const exitRound = () => {
+ token.current++;
+ playing.current = false;
+ engine.stop();
+ composing.current = false;
+ dispatch({ type: "EXIT" });
+ };
+ // 문항 전환은 청취를 강제하지 않는다. 준비된 사용자가 다음 소리를 시작한다.
+ 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";
+
+ // 현재 재생의 gain은 바꾸지 않는다. 다음 재청취/미응답 문항/새 라운드에 적용한다.
+ const selectedSnr = snrOverride ?? LEVELS[(question?.level ?? startingLevel) - 1]!.db;
+ const snrControl = (
+ <div className="snr-control">
+ <label htmlFor="snr">목표 음성 SNR</label>
+ <input id="snr" type="range" min={SNR_MIN} max={SNR_MAX} step="1"
+ value={selectedSnr} aria-describedby="snr-help"
+ onChange={event => {
+ const db = normalizeSnr(Number(event.target.value), LEVELS[(question?.level ?? startingLevel) - 1]!.db);
+ setSnrOverride(db);
+ dispatch({ type: "SNR", db });
+ }} />
+ <output htmlFor="snr">{formatSnr(selectedSnr)}</output>
+ <p id="snr-help">{describeSnr(selectedSnr)} 범위 −12~+12 dB · 1 dB 간격.
+ 양수는 음성이 더 크게, 0은 같게, 음수는 더 작게 들려요.
+ 변경은 다음 재생·재청취부터 적용돼요.</p>
+ </div>
+ );
+
+ // 상태와 엔진 연결은 공유하고 각 화면에는 컨트롤 하나만 배치한다.
+ // 위치가 바뀌어도 기존 접근성 이름·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.1</span>
+ </div>
+ <div className="header-actions">
+ {game && game.phase !== "results" ? (
+ <button className="text-button" onClick={game.phase === "results" ? home : exitRound}>
+ {game.phase === "results" ? "처음 화면으로" : "문제 풀이 종료"}
+ </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>한 라운드 {ROUND_SIZE}개의 질문</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">라운드당 약 1–2분</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>
+ <div className="level-field">
+ <label htmlFor="starting-level">시작 난이도</label>
+ <select id="starting-level" value={startingLevel} onChange={event => {
+ setStartingLevel(Number(event.target.value));
+ setSnrOverride(null);
+ }}>
+ {LEVELS.map(level => <option key={level.id} value={level.id}>{level.id}단계 · {level.name} · {formatSnr(level.db)}</option>)}
+ </select>
+ </div>
+ {snrControl}
+ {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">{game.endedEarly ? "지금까지의 소리 탐험" : "세 번의 귀 기울임, 하나의 발견"}</span>
+ <div className="result-mascot">
+ <Mascot small />
+ </div>
+ <h2 ref={focusTarget} tabIndex={-1}>
+ {game.endedEarly ? "문제 풀이를 종료했어요." : "오늘의 소리 탐험을 마쳤어요."}
+ </h2>
+ <p className="muted">
+ 작은 소리에 집중한 당신, 다음 탐험에서도 만나요.
+ </p>
+ <p>응답 {result.answered}문항 · 정답 {result.correct}문항</p>
+ <div className="stats">
+ <div>
+ <strong>{result.score.toLocaleString()}</strong>
+ <span>총 점수</span>
+ </div>
+ <div>
+ <strong>{result.accuracy}%</strong>
+ <span>정답률 · {result.correct}/{result.answered}</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>
+ {[...new Set(game.answers.filter(a => a.level === row.level).map(a => a.db))].map(formatSnr).join(", ") || formatSnr(LEVELS[row.level - 1]!.db)}
+ </td>
+ <td>
+ {row.correct} / {row.total}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ <p className="muted">단계 × 100점 + 연속 정답 보너스 (최대 40점)</p>
+ {!game.endedEarly && (
+ <button className="primary" onClick={() => begin(Math.min(5, question!.level + 1))}>
+ {question!.level < 5 ? `다음 난이도 · ${question!.level + 1}단계` : "5단계 다시 도전"}
+ </button>
+ )}
+ <button className="secondary" 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> / {game.questions.length}
+ </span>
+ </div>
+ <progress
+ max={game.questions.length}
+ 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">
+ {LEVELS[question.level - 1]!.name}
+ </p>
+ {snrControl}
+ {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 === game.questions.length - 1 ? "결과 보기" : "다음 문제"}{" "}
+ <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:19:51
17| "docs/AUDIO_PROVENANCE.md",
18| ]) {
19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
| ^
20| }
21| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/20]⎯
FAIL tests/neural-release.test.ts > 0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: index.html: expected '<!doctype html>\n<!-- 문서 언어를 한국어로 명시하…' to contain '0.2.3'
- Expected
+ Received
- 0.2.3
+ <!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.1</title>
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module" src="/src/main.tsx"></script>
+ </body>
+ </html>
+
❯ tests/neural-release.test.ts:19:51
17| "docs/AUDIO_PROVENANCE.md",
18| ]) {
19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
| ^
20| }
21| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/20]⎯
FAIL tests/neural-release.test.ts > 0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1.1\n\n소음 속에…' to contain '0.2.3'
- Expected
+ Received
- 0.2.3
+ # 단어를 맞춰라 · Find-Word v0.1.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 포트를
+ 테스트 중에만 열고 종료합니다. 스크린샷과 101개 WAV의 query URL·해시·디코드
+ 결과는 `test-results/`에 저장합니다. 인수 기록은 실행별 새 evidence 경로에
+ 복사하여 `.agent/evidence-v0.1.1/`의 과거 결과를 보존합니다.
+
+ ## 음성과 문서
+
+ 한국어 WAV 101개는 직접 쓴 문장을 Microsoft Edge online TTS의
+ `ko-KR-HyunsuMultilingualNeural`로 새로 생성했습니다. edge-tts **7.3.1**,
+ rate `-6%`, pitch `-4Hz`이며 FFmpeg로 **24kHz mono PCM16**으로 변환합니다.
+ 생성 시 네트워크가 필요하며 앱은 저장된 WAV만 재생합니다. 환경음은 브라우저에서
+ 고정 seed로 합성합니다. 생성 음성의 독점 권리를 주장하지 않습니다.
+
+ intro와 목표 WAV는 모두 `/audio/파일명.wav?v=0.1.1`로 요청합니다.
+ `src/audio/assets.ts`의 자산 버전 한 곳을 사용하며, 같은 파일명으로 음성을
+ 교체할 때 이 값을 갱신해 기존 7일 캐시와 구분합니다. 제품 버전은 0.1.1입니다.
+
+ Linux에서 Python 3.11 이상과 FFmpeg를 준비한 뒤 다음 명령을 사용합니다.
+ 클라이언트 버전을 고정해도 원격 모델 변경으로 동일 바이트 재생성은 보장하지 않습니다.
+
+ ```bash
+ python3 -m venv .venv-audio
+ .venv-audio/bin/python -m pip install -r requirements-audio.txt
+ .venv-audio/bin/python -m pip check
+ npm run audio:generate
+ ```
+
+ 고정 venv의 Python이 101개 모두 임시 생성·검증하고 디렉터리를 원자 교체합니다.
+ 요청별 60초 제한과 최대 3회 시도를 적용하며, 실패 시 기존 자산을 보존합니다.
+ 청취 샘플: [안내](public/audio/intro.wav), [행운의 사과](public/audio/luck-apple.wav).
+ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
+ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
+
+ - [설계와 상태 전이](docs/ARCHITECTURE.md)
+ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md)
+ - [v0.1.1 RED/GREEN 및 실제 검증 결과](.agent/RESULT-v0.1.1.md)
+ - [v0.1 역사 기록](.agent/RESULT.md)
+
+ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다.
+
❯ tests/neural-release.test.ts:19:51
17| "docs/AUDIO_PROVENANCE.md",
18| ]) {
19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
| ^
20| }
21| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/20]⎯
FAIL tests/neural-release.test.ts > 0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: LICENSE: expected 'Find-Word v0.1.1\nPrivate / proprieta…' to contain '0.2.3'
- Expected
+ Received
- 0.2.3
+ Find-Word v0.1.1
+ Private / proprietary. All rights reserved.
+
+ No license is granted to use, copy, modify, publish, distribute, sublicense,
+ or sell the original project code or documentation 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.
+
+ This project does not claim exclusive rights to generated speech.
+ Provider terms and any third-party rights remain applicable to generated audio;
+ the project notice does not grant or warrant rights in that output.
+ See docs/AUDIO_PROVENANCE.md.
+
❯ tests/neural-release.test.ts:19:51
17| "docs/AUDIO_PROVENANCE.md",
18| ]) {
19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
| ^
20| }
21| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/20]⎯
FAIL tests/neural-release.test.ts > 0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# Find-Word v0.1.1 오디오 출처와 재현\n\n## 포…' to contain '0.2.3'
- Expected
+ Received
- 0.2.3
+ # Find-Word v0.1.1 오디오 출처와 재현
+
+ ## 포함 자산과 생성 설정
+
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
+ | --- | --- | --- | --- |
+ | `public/audio/intro.wav` | Microsoft Edge online TTS neural 음성 | `src/content.ts`의 INTRO | 24kHz mono PCM16 WAV |
+ | 목표 음성 100개 | 같은 provider·voice | 10 패턴 × 10 단어, `renderSentence` | 같은 WAV |
+ | 8개 배경 환경 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS |
+
+ voice는 **`ko-KR-HyunsuMultilingualNeural`**, rate는 **`-6%`**, pitch는
+ **`-4Hz`**, 생성 클라이언트는 **edge-tts 7.3.1**이다. 직접 작성한 한국어
+ 문장을 온라인 provider에 보내 MP3 응답을 받은 뒤 FFmpeg로 PCM을 만든다.
+ 음성 복제나 사람의 녹음 샘플을 사용하지 않는다. 웃음은 INTRO의 ‘으하하하~~’를
+ 같은 voice로 합성한다. 앱은 저장한 WAV만 같은 출처에서 가져오며 런타임 외부
+ TTS/API, speechSynthesis, 인증정보가 필요하지 않다.
+
+ 청취용 샘플은 [intro.wav](../public/audio/intro.wav)와
+ [luck-apple.wav](../public/audio/luck-apple.wav)이다. 101개 전체 생성 결과의
+ 크기·길이 범위와 독립 검증은 [v0.1.1 RESULT](../.agent/RESULT-v0.1.1.md)에 기록한다.
+ `public/audio/manifest.json`은 version, provider, voice, rate, pitch, sampleRate,
+ 생성 시각, 실측 edge-tts/FFmpeg 버전, 파일별 원문·SHA-256·bytes·duration·RMS·peak를 담는다.
+
+ ## 설치와 생성
+
+ 생성 환경은 Linux (기존 디렉터리 교체에 `renameat2(RENAME_EXCHANGE)` 필요),
+ Python 3.11 이상, FFmpeg, Node.js 22.22.2 이상이다. **이미 포함된 WAV를
+ 빌드·실행할 때는 Python/FFmpeg/provider 연결이 필요 없다.** 생성기 계약 테스트는
+ Python과 FFmpeg를 사용하되 네트워크 없이 동작한다.
+
+ ```bash
+ python3 -m venv .venv-audio
+ .venv-audio/bin/python -m pip install -r requirements-audio.txt
+ .venv-audio/bin/python -m pip check
+ npm run audio:generate
+ npm test
+ npm run build
+ ```
+
+ `audio:generate`는 `.venv-audio/bin/python scripts/generate_audio.py`로 고정되어
+ 전역 Python으로 대체되지 않는다. `requirements-audio.txt`는 edge-tts==7.3.1과
+ 전이 의존성 버전을 고정한다. 생성기는 실제 설치 버전을 조회하고 7.3.1이 아니면
+ 외부 요청 전에 실패한다. `.venv-audio/`는 Git과 배포물에서 제외한다.
+
+ 원문을 Node type stripping으로 앱 데이터에서 읽고, 101개 모두 같은 파일시스템의
+ `public/.audio-stage-*/`에 순차 생성한다. 각 provider 요청은 최대 60초, 최대
+ 3회 시도이며 실패 사이 1초/2초를 기다린다. FFmpeg도 60초 제한이 있다.
+ 재시도 전 불완전 MP3를 삭제하고 변환·검증 실패는 즉시 중단한다. 로그에는
+ 파일명·시도 횟수만 남기고 provider stderr/응답·쿠키·인증 헤더를 저장하지 않는다.
+
+ 전체 WAV와 manifest를 검증한 뒤 기존 `public/audio`와 완성 디렉터리를 단일
+ 원자 교환으로 바꾼다. 실패·Ctrl-C는 기존 디렉터리의 바이트를 그대로 보존한다.
+ 원자 교환을 지원하지 않는 파일시스템에서는 실패하며 비원자 방식으로 우회하지 않는다.
+ 성공 후 이전 자산과 임시 MP3를 정리한다. 강제 종료(SIGKILL)·전원 손실 때는
+ 숨김 staging 폴더가 남을 수 있으나 공개 디렉터리는 교환 전 또는 후의 전체 집합이다.
+ 이는 전원 손실 시 저장장치 내구성 보장을 뜻하지 않는다.
+
+ 고정 클라이언트와 설정으로 **생성 절차**를 재현할 수 있다. 원격 모델·서비스 버전은
+ 고정할 수 없어 재생성 WAV의 바이트 동일성은 보장하지 않는다. 새 실행의 manifest를
+ 검토해야 한다. 빌드는 이미 생성한 WAV의 해시를 보존하고 101개 모두 포함됐는지 확인한다.
+
+ ## 레벨 처리와 검증
+
+ MP3를 24kHz mono signed 16-bit little-endian PCM으로 디코드한다. 파일 전체에
+ 동일한 선형 gain을 적용해 RMS 0.10을 목표로 하며 증폭은 최대 2배, peak는
+ 0.89로 제한한다. 동적 압축·노이즈 게이트·침묵 제거·시간 변형은 하지 않는다.
+ 원래 신호의 자음과 억양을 보존하면서 파일 간 레벨 차이와 피크를 완화한다.
+
+ 생성기와 독립 자산 테스트는 101개 파일의 원문·집합·SHA-256·bytes·duration,
+ 24kHz mono PCM16, 1–20초 길이, RMS > 100/32768, full-scale 미도달,
+ 총 48MiB 미만을 검사한다. 런타임 RMS 0.03과 최대 +6dB에서도 peak < 0.8을
+ 요구한다. 기존 앱은 Web Audio에서 배경·음성을 RMS 정규화한 뒤 상대 SNR을 적용한다.
+ 물리적 음압이나 주파수별 청감을 보정한 값은 아니다.
+
+ ## 도구 라이선스와 서비스 조건
+
+ - [edge-tts 7.3.1 LICENSE](https://github.com/rany2/edge-tts/blob/7.3.1/LICENSE):
+ 대부분의 코드에 LGPLv3, `srt_composer.py`에만 MIT가 적용된다.
+ [공식 프로젝트](https://github.com/rany2/edge-tts)는 Microsoft Edge의 온라인
+ TTS를 이용하는 클라이언트다. 클라이언트 라이선스가 provider 사용·음성 출력의
+ 재배포 권한을 대신 부여하지 않는다. 이 프로젝트는 클라이언트/모델을 번들하지 않는다.
+ - [FFmpeg 공식 라이선스](https://ffmpeg.org/legal.html): 빌드 옵션에 따라 LGPL/GPL이
+ 적용된다. 실제 사용 버전은 manifest에 남기며 실행 파일은 배포하지 않는다.
+ - [Microsoft Services Agreement](https://www.microsoft.com/en-us/servicesagreement)의
+ AI Services 등 해당 서비스 조건은 도구의 오픈소스 라이선스와 별개다.
+ 위 문서 §13(s)(vi)는 출력의 권리·사용 가능성을 이용자가 판단하도록 설명한다.
+ API 키 없이 호출된다는 사실이 상업 사용·재배포의 포괄적 허락이나 SLA를 뜻하지 않는다.
+ 배포자는 자신의 이용 상황에 적용되는 최신 조건을 확인해야 한다.
+ - 프로젝트는 생성 음성 자체에 **독점 권리를 주장하지 않는다**. [LICENSE](../LICENSE)의
+ 소스·문서 권리 유보와 provider 및 제3자의 권리는 구분한다. 런타임 React,
+ ReactDOM, scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에 있다.
+
+ 문서 확인일: 2026-10-14 UTC. provider의 음성 목록, rate/pitch 지원, 가용성,
+ 접근 제한, 조건은 바뀔 수 있다. 온라인 생성이 중단되어도 포함된 WAV의 빌드·재생에는
+ 영향이 없다. 실패 시 다른 voice로 자동 대체하지 않는다.
+
+ ## 청감과 평가의 한계
+
+ 자동 검증은 PCM 신호·무결성·재생 흐름을 확인한다. 한국어 발음, 유사 자모의
+ 구분, 웃음의 자연스러움과 실제 사용자의 난이도는 청취 평가가 필요하다.
+ 대표 샘플과 게임 내 소음 혼합 상태를 직접 들어 인수 판단한다. 배경은 장소를
+ 추상화한 합성 신호다. 이 게임의 수치는 임상 검증·치료·청력 판정용이 아니다.
+
❯ tests/neural-release.test.ts:19:51
17| "docs/AUDIO_PROVENANCE.md",
18| ]) {
19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
| ^
20| }
21| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[13/20]⎯
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다
AssertionError: docs/ARCHITECTURE.md: expected '# Find-Word v0.1.1 설계\n\n## 데이터와 출제\n…' to contain '0.2.3'
- Expected
+ Received
- 0.2.3
+ # Find-Word v0.1.1 설계
+
+ ## 데이터와 출제
+
+ `src/content.ts`는 패턴, 질문, 단어, 오답 규칙, 배경 프리셋, 상대 dB를 서로 다른
+ 배열로 보관한다. `renderSentence`가 각 패턴의 `{word}`를 정답으로,
+ `{distractor1}`·`{distractor2}`를 해당 보기의 오답으로 채운다. 같은 함수를
+ 음성 생성기도 사용하며 런타임 문장과 WAV의 대응은 manifest 테스트가 검증한다.
+
+ `Pattern.mechanic`은 다음 10개 고유 청취 연산을 나타낸다. 인용 단어를 활용형으로
+ 바꾸지 않으므로 동사·부사 표제어가 오답으로 나와도 문장이 자연스럽다.
+
+ | mechanic | 청취 과제 |
+ | --- | --- |
+ | `direct-target` | 한 개의 목표 단어를 직접 식별 |
+ | `distractor-password` | 연습 암호를 제외하고 진짜 암호 선택 |
+ | `ignore-then-remember` | 앞 단어를 잊고 이후에 지시된 단어 기억 |
+ | `first` | 세 단어 중 처음 선택 |
+ | `last` | 세 단어 중 마지막 선택 |
+ | `middle` | 세 단어 중 가운데 선택 |
+ | `repetition` | 오답 사이에서 두 번 등장한 단어 찾기 |
+ | `correction` | 처음 답을 취소하고 정정된 답 선택 |
+ | `conditional-cue` | 초록/빨강 규칙과 현재 신호를 함께 적용 |
+ | `spatial-right-cue` | 좌우 표지 중 오른쪽에 해당하는 단어 선택 |
+
+ 방향 단서는 문장의 의미이며 stereo 위치 재현 과제는 아니다. 모든 음성은 mono다.
+ 직접 식별을 제외한 문장은 실제 보기의 경쟁 단어도 발화하므로, 들린 단어 하나를
+ 무조건 고르는 것으로 해결되지 않는다. 테스트는 100개 문장 전체에서 단어 순서,
+ 목표 반복 횟수, 취소·조건·방향 단서와 질문 의도를 검증한다.
+
+ `src/game.ts`의 `createQuestions`는 seed 기반 PRNG와 Fisher–Yates로
+ 유형·단어·정답 위치를 섞는다. 한 세션에서 유형과 정답 단어는 각각 한 번씩
+ 나온다. 100개 문장 조합에 응답 방식·환경·난이도를 독립적으로 결합한다.
+
+ 오답의 `rule`은 `RULE_DIFFICULTY`에 연결된다. 음절 교체 1, 초성 교체 2,
+ 모음 교체 3, 받침 교체 4는 편집상 예상 혼동 순서이며 검증된 청각 척도가
+ 아니다. 모든 문항에 서로 다른 세 변형을 포함한다. 음량 단계는 이 값과 분리된다.
+
+ ## 게임 상태
+
+ ```mermaid
+ stateDiagram-v2
+ [*] --> ready
+ ready --> loading: PLAY
+ loading --> playing: STARTED
+ loading --> ready: ERROR
+ playing --> ready: ERROR / 중단
+ playing --> answer: ENDED
+ answer --> loading: 다시 듣기
+ answer --> feedback: ANSWER
+ feedback --> ready: NEXT (다음 문제)
+ feedback --> results: NEXT (마지막 문제)
+ ```
+
+ 허용되지 않은 이벤트는 동일 상태를 반환한다. 재생 전/중에는 채점할 수 없고,
+ 피드백 이후의 중복 답변·다음 클릭도 한 번만 반영된다. UI에서도 버튼 비활성화와
+ 동기 ref 잠금을 함께 사용한다. 세션 토큰으로 처음 화면 복귀·unmount 뒤 완료된
+ Promise를 무시한다. 질문과 보기는 재생 완료 후 DOM에 생성되며 정답 문장은
+ 채점 후에만 표시된다.
+
+ 주관식은 NFC 한글 정규화 후 공백과 유니코드 문장부호만 제거한다. 비어 있는
+ 답변과 부분 일치는 정답으로 인정하지 않는다. React 텍스트 노드로만 출력하여
+ 입력을 HTML로 해석하지 않는다.
+
+ 점수는 정답 시 `100 × 단계 + 10 × min(연속 정답 - 1, 4)`이다. 오답은 0점,
+ 현재 연속 정답을 초기화한다. 최고 연속 정답과 난이도별 정답 수는 유지한다.
+
+ ## 오디오
+
+ 101개 저장 WAV는 edge-tts 7.3.1의 `ko-KR-HyunsuMultilingualNeural`로
+ 빌드 전에 생성한다. 24kHz mono PCM16이며 원문·도구·해시는 manifest에 남긴다.
+ 생성기는 임시 디렉터리에서 전체 검증 후 원자 교체한다. 생성 절차는
+ [AUDIO_PROVENANCE](AUDIO_PROVENANCE.md)에 있으며 앱은 provider에 연결하지 않는다.
+
+ `src/audio/dsp.ts`는 순수 수치 함수이며 장치가 없어도 테스트할 수 있다.
+ 정수 seed 잡음에 저역 통과 필터, 사인파, 진폭 변조를 조합하고 양 끝을 페이드한다.
+ 8개 프리셋은 실제 현장 녹음이 아니라 환경을 추상적으로 표현한 합성 신호다.
+
+ `src/audio/engine.ts`는 클릭 이벤트에서 AudioContext 생성·resume을 호출한 뒤
+ 같은 출처의 WAV를 로드한다. mono PCM을 decode하고 음성과 배경의 전체 버퍼
+ RMS를 0.03으로 정규화한다. `gain = 10^(dB/20)`으로 목표 gain을 지정한다.
+ 시간별·주파수별 청감이나 물리적 dB SPL을 보장하는 수치는 아니다.
+
+ `src/audio/assets.ts`는 음성 자산 버전과 URL 생성을 한 곳에서 관리한다.
+ intro와 목표 100개 모두 `?v=0.1.1`을 붙여 동일 파일명에 남은 이전 캐시를
+ 피한다. 파일을 다시 생성하면 제품 버전 유지 여부와 관계없이 자산 버전을
+ 갱신한다. 버전 확인을 위한 외부 요청이나 런타임 manifest 요청은 없다.
+
+ ```text
+ 배경 BufferSource → 배경 Gain (1, 시작/끝 fade) ─┐
+ 목표 BufferSource → 음성 Gain (10^(dB/20)) ─────┼→ master Gain → destination
+ 안내 BufferSource → 안내 Gain (1.8 → 0.12) ─────┘
+ ```
+
+ 첫 문제에서는 웃음과 안내 음성을 낮추어 배경에 묻히게 한 후 목표 문장을
+ 재생한다. 이후에는 목표만 재생한다. 안내 도중 실패했으면 재시도 시 안내도
+ 다시 재생한다. 기본 master는 35%이며 음량 조절은 두 신호의 비율을 유지한다.
+
+ 동시 play는 거부한다. 정지·실패·완료는 onended 해제, 소스 stop/disconnect,
+ AbortController 취소, 타이머 제거를 수행한다. load/decode 뒤에도 작업 토큰을
+ 검사하여 취소된 이전 작업이 소리를 시작하지 못하게 한다. 준비 15초,
+ 재생 길이 + 5초의 제한으로 무한 대기를 피한다. unmount는 context까지 닫는다.
+
+ ## 화면·접근성·보안
+
+ `App.tsx`는 시작·플레이·결과 화면, 음량, 모드, 배경 선택을 담당한다.
+ 정답/피드백/결과 제목으로 초점을 이동하고 결과표에 caption에 해당하는
+ 접근성 이름과 scope를 둔다. 네이티브 radio/select/button/form으로 방향키,
+ Tab/Enter와 터치 조작을 지원한다. 장식 파형·캐릭터는 aria-hidden이며
+ reduced motion에서는 파형이 움직이지 않는다. 오류는 role=alert로 전달한다.
+
+ 외부 폰트, CDN, 이미지, 분석 도구, 로그인, 쿠키, localStorage 사용이 없다.
+ 개인 입력과 점수는 현재 React 메모리에만 존재한다. 단어·정답은 클라이언트
+ 번들에 있으므로 개발자 도구를 통한 정답 열람을 막는 시험 시스템은 아니다.
+
+ ## 검증 경계
+
+ Vitest는 실제 RED/GREEN으로 데이터·채점·상태·오디오 그래프·화면을 검증한다.
+ Playwright는 production build와 실제 Chrome AudioContext로 두 크기의 10문제
+ 완주·재청취·오류 복구·axe 검사를 수행한다. 테스트 자동화는 한국어 발음의
+ 자연스러움, 실제 사용자의 청각 난이도, 모바일 Safari의 장치별 정책까지
+ 증명하지 않으므로 인수 시 직접 청취를 추가한다.
+
❯ tests/neural-release.test.ts:49:51
47| it("현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다", () => {
48| for (const path of ["docs/ARCHITECTURE.md", "docs/DEPLOYMENT.md", "d…
49| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
| ^
50| const history = readFileSync("CHANGELOG.md", "utf8");
51| for (const version of ["0.2.3", "0.1.1", "0.1.0"]) expect(history).t…
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/20]⎯
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다
AssertionError: docs/DEPLOYMENT.md: expected '# Find-Word v0.1.1 정적 배포 인계\n\n이 문서는 …' to contain '0.2.3'
- Expected
+ Received
- 0.2.3
+ # Find-Word v0.1.1 정적 배포 인계
+
+ 이 문서는 **Neo가 검토 후 수행할 절차**다. Codex는 서버, DNS, 인증정보에
+ 접근하지 않았고 배포·서비스 생성·Git commit/push도 하지 않았다.
+
+ ## 산출물
+
+ Node.js 최소 **22.22.2** (`>=22.22.2`)의 지원 LTS 환경에서 `npm ci`, `npm run lint`, `npm test`,
+ `npm run build`를 실행한다. `dist/` 전체를 하나의 릴리스로 전달한다.
+ `dist/audio/`의 24kHz neural WAV 101개와 version 0.1.1 manifest가 반드시 포함되어야 한다. 런타임은
+ Nginx 정적 파일만 필요하며 Node 서버·TTS 엔진·DB·환경변수·API 키는 필요 없다.
+ 잠긴 jsdom 계열 의존성 때문에 24.x는 24.15.0 이상, 26.x는 26.0.0 이상이어야 한다.
+ 브라우저 검증의 Chromium sandbox는 기본 활성화다. sandbox를 지원하지 않는
+ 격리된 개발 환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`로 명시적으로 끈다.
+
+ Canonical repository는 [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word)다.
+ 프로젝트 자체는 private / all-rights-reserved이며 [LICENSE](../LICENSE)를 따른다.
+ 생성 음성은 프로젝트의 독점 권리 주장 대상이 아니며 provider 조건은
+ [AUDIO_PROVENANCE](AUDIO_PROVENANCE.md)에 기록한다.
+ React, ReactDOM, scheduler의 MIT 전문을 담은 `dist/THIRD_PARTY_NOTICES.md`도
+ 반드시 배포한다. `build` 검증기는 고지 파일이 없거나 축약·변조되면 실패한다.
+
+ 현재 앱은 도메인 루트(`/`) 배포 기준이다. 하위 경로 배포 시 Vite base뿐 아니라
+ 음성 URL도 함께 변경하고 테스트해야 한다. 예정 도메인은 `findword.heart-m.com`이다.
+
+ ## Nginx 예시
+
+ 아래 root는 예시다. 실제 경로·기존 서버 블록·인증서는 Neo가 확인한다.
+ 기존 서비스 설정을 덮어쓰지 않고 검토 후 적용한다.
+
+ ```nginx
+ # 정적 릴리스 디렉터리를 가리킨다. 인증서와 HTTPS 설정은 운영자가 별도 추가한다.
+ server {
+ listen 80;
+ server_name findword.heart-m.com;
+ root /var/www/findword/current;
+ index index.html;
+
+ # 기본 MIME 정의로 JS/CSS/WAV가 올바른 타입으로 제공되게 한다.
+ include /etc/nginx/mime.types;
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy no-referrer always;
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
+
+ # 파일명이 해시로 바뀌는 번들만 장기 캐시한다.
+ location /assets/ {
+ try_files $uri =404;
+ expires 1y;
+ }
+
+ # 앱은 intro·목표 모두 같은 자산 버전 query를 붙여 이전 캐시와 구분한다.
+ # $uri는 query를 제외한 실제 WAV 경로다. 누락 시 index.html 대신 404로 응답한다.
+ location /audio/ {
+ try_files $uri =404;
+ expires 7d;
+ }
+
+ # SPA의 직접 진입·새로고침을 index.html로 받되 HTML은 재검증한다.
+ location / {
+ try_files $uri $uri/ /index.html;
+ expires -1;
+ }
+ }
+ ```
+
+ ## 인수 확인
+
+ 1. 서버에 적용하기 전 `nginx -t`, 기존 서비스 충돌, 파일 읽기 권한을 확인한다.
+ 2. TLS/HTTPS와 DNS를 운영자가 확인한다. 실제 도메인에서 시작 후 음성이 들리는지 확인한다.
+ 3. `audio/intro.wav?v=0.1.1`와 목표 WAV의 같은 버전 query가 정상 응답하고
+ 존재하지 않는 오디오는 query가 있어도 404인지 확인한다.
+ 4. 모바일 Safari/Chrome, 데스크톱에서 음량·중단·다시 듣기·10문제 완주를 직접 확인한다.
+ 5. 원자적으로 릴리스 디렉터리를 전환하고 이전 디렉터리를 보관해 되돌릴 수 있게 한다.
+
+ 오프라인 캐시를 제공하는 PWA는 아니다. 정적 자산의 최초 로드에는 해당 서버
+ 연결이 필요하다. HTML은 재검증하고, 오디오는 URL의 자산 버전으로 구분한다.
+ 같은 파일명으로 WAV를 교체할 때 `src/audio/assets.ts`의 `AUDIO_ASSET_VERSION`도
+ 갱신한다. 제품 버전을 유지하는 음성 수정에서도 새 자산 버전이 필요하다.
+ 이 query는 로컬 정적 파일 요청에만 사용하며 런타임 외부 요청을 추가하지 않는다.
+
❯ tests/neural-release.test.ts:49:51
47| it("현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다", () => {
48| for (const path of ["docs/ARCHITECTURE.md", "docs/DEPLOYMENT.md", "d…
49| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
| ^
50| const history = readFileSync("CHANGELOG.md", "utf8");
51| for (const version of ["0.2.3", "0.1.1", "0.1.0"]) expect(history).t…
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/20]⎯
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다
AssertionError: docs/WORDS.md: expected '# 단어와 오답 변형\n\n임의의 한글 조합을 자동 생성하지 않고 …' to contain '0.2.3'
- Expected
+ Received
- 0.2.3
+ # 단어와 오답 변형
+
+ 임의의 한글 조합을 자동 생성하지 않고 사전에 있는 표제어만 고른다. 다음의
+ 짧은 의미 구분은 앱 작성자가 작성한 설명이며 사전의 정의·예문·음성을
+ 복제한 데이터셋이 아니다. 표기 단어는 `src/content.ts`에 명시적으로 저장한다.
+
+ | 정답 | 초성 교체 | 모음 교체 | 세 번째 변형 |
+ | --- | --- | --- | --- |
+ | 사과 | 다과 (차와 간식) | 사고 (뜻밖의 사건) | 사자 (동물), 음절 교체 |
+ | 바다 | 사다 (구매하다) | 보다 (눈으로 살피다) | 받다 (수령하다), 받침 추가 |
+ | 나무 | 나루 (배가 오가는 곳) | 너무 (정도가 지나치게) | 나물 (먹는 풀), 받침 추가 |
+ | 모자 | 포자 (생물의 번식 단위) | 모조 (원본을 본뜸) | 목자 (양을 돌보는 사람), 받침 추가 |
+ | 우산 | 부산 (도시 이름) | 오산 (잘못된 계산) | 우상 (숭배의 대상), 받침 교체 |
+ | 시장 | 지장 (일을 방해하는 사정) | 사장 (회사 책임자) | 신장 (몸의 키), 받침 추가 |
+ | 기름 | 이름 (대상을 부르는 말) | 거름 (식물에 주는 영양) | 기틀 (기본 구조), 음절 교체 |
+ | 소리 | 오리 (새) | 수리 (고침) | 소금 (조미료), 음절 교체 |
+ | 고기 | 보기 (예시) | 고개 (목 위의 머리) | 공기 (대기), 받침 추가 |
+ | 가구 | 하구 (강이 바다와 만나는 곳) | 가교 (연결하는 다리) | 강구 (방안을 찾음), 받침 추가 |
+
+ 동사·부사는 사전 표제어 형태를 사용하고 부산은 사전에 등재된 고유명사로
+ 사용한다. 문장의 의미 적합성을 오답 생성 규칙으로 삼지 않고 **들은 낱말의
+ 소리 차이**를 묻는다. 이는 의미상 동의어가 아니라 음운상 비슷한 오답이다.
+
+ 참조 사전은 국립국어원의 [한국어기초사전](https://krdict.korean.go.kr/kor/mainAction),
+ [표준국어대사전](https://stdict.korean.go.kr/main/main.do),
+ [우리말샘](https://opendict.korean.go.kr/)이다. 혼동하기 쉬운 일부 표제어의
+ 직접 확인 링크는 [하구](https://krdict.korean.go.kr/kor/dicSearch/SearchView?ParaWordNo=85229),
+ [목자](https://krdict.korean.go.kr/eng/dicSearch/SearchView?ParaWordNo=55879),
+ [부산](https://krdict.korean.go.kr/kor/dicSearch/SearchView?ParaWordNo=68895)이다.
+ [국립국어원의 나루 설명](https://www.korean.go.kr/front/mcfaq/mcfaqView.do?mcfaq_seq=8815)도 참고한다.
+ 앱 실행 중에는 사전에 접속하지 않는다.
+
+ `tests/content.test.ts`는 한글 완성형을 초성·중성·종성으로 분해한다. 초성·모음·받침
+ 변형은 정확히 한 자모만 바뀌는지 확인한다. 음절 교체는 더 큰 차이를 허용한다.
+ 받침 추가도 종성 0에서 다른 값으로 바뀌는 ‘받침 교체’로 분류한다.
+
+ `RULE_DIFFICULTY`는 음절 1, 초성 2, 모음 3, 받침 4의 예상 혼동 순서를
+ 기록한다. 각 묶음의 세 오답은 서로 다른 규칙과 순위를 가진다. 이 순서는
+ 출제 메타데이터이며 청감·발음·청력에 대한 실측 난이도나 임상 척도가 아니다.
+ 새 단어를 넣을 때는 표제어·의미·변형 규칙을 검토하고 이 목록을 갱신한다.
+
❯ tests/neural-release.test.ts:49:51
47| it("현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다", () => {
48| for (const path of ["docs/ARCHITECTURE.md", "docs/DEPLOYMENT.md", "d…
49| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
| ^
50| const history = readFileSync("CHANGELOG.md", "utf8");
51| for (const version of ["0.2.3", "0.1.1", "0.1.0"]) expect(history).t…
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/20]⎯
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다
AssertionError: public/audio/manifest.json: expected '{\n "version": "0.1.1",\n "provider…' to contain '0.2.3'
- Expected
+ Received
- 0.2.3
+ {
+ "version": "0.1.1",
+ "provider": "Microsoft Edge online TTS",
+ "voice": "ko-KR-HyunsuMultilingualNeural",
+ "rate": "-6%",
+ "pitch": "-4Hz",
+ "sampleRate": 24000,
+ "channels": 1,
+ "sampleFormat": "PCM s16le",
+ "toolVersions": {
+ "edge-tts": "7.3.1",
+ "ffmpeg": "ffmpeg version 6.1.1-3ubuntu5 Copyright (c) 2000-2023 the FFmpeg developers"
+ },
+ "generatedAtUtc": "2026-10-14T06:55:41.751382+00:00",
+ "source": "https://github.com/rany2/edge-tts",
+ "method": "온라인 neural 생성 MP3 → FFmpeg decode → 파일별 선형 gain → 로컬 PCM WAV",
+ "normalization": {
+ "targetRms": 0.1,
+ "peakCeiling": 0.89,
+ "maxGain": 2.0
+ },
+ "files": [
+ {
+ "file": "intro.wav",
+ "text": "으하하하~~ 나는 에스다. 널 위한 행운의 단어를 불러주마, 잘 찾아보거라.",
+ "sha256": "90d9f46e6e3f9c8a0c1a781096df54d7809ef4ad38b289508ef7b8c0411465ce",
+ "bytes": 338732,
+ "duration": 7.056,
+ "rms": 0.09999999766232884,
+ "peak": 0.499847412109375
+ },
+ {
+ "file": "luck-apple.wav",
+ "text": "오늘의 행운을 담은 단어는 ‘사과’입니다.",
+ "sha256": "7c8f413f4b7e164f090e508ba090448f1376c6012649bbb6a917d45851313883",
+ "bytes": 188972,
+ "duration": 3.936,
+ "rms": 0.10000000059246013,
+ "peak": 0.401641845703125
+ },
+ {
+ "file": "luck-sea.wav",
+ "text": "오늘의 행운을 담은 단어는 ‘바다’입니다.",
+ "sha256": "47aa31d757c9fb49c9b53a1c052d90a5ced4be57549d649850d9b48f8864045b",
+ "bytes": 191276,
+ "duration": 3.984,
+ "rms": 0.09999997583491328,
+ "peak": 0.388702392578125
+ },
+ {
+ "file": "luck-tree.wav",
+ "text": "오늘의 행운을 담은 단어는 ‘나무’입니다.",
+ "sha256": "122f496834fb16d6da47f801e50e46e272e4e934af959467d1db4e490f79be7a",
+ "bytes": 183212,
+ "duration": 3.816,
+ "rms": 0.1000000469252297,
+ "peak": 0.36920166015625
+ },
+ {
+ "file": "luck-hat.wav",
+ "text": "오늘의 행운을 담은 단어는 ‘모자’입니다.",
+ "sha256": "4ff43dd042f4b5451e9fdeb3edd59f1e389a7d1dd488094587a3a9fc7dfffa44",
+ "bytes": 191276,
+ "duration": 3.984,
+ "rms": 0.09999997899323479,
+ "peak": 0.417083740234375
+ },
+ {
+ "file": "luck-umbrella.wav",
+ "text": "오늘의 행운을 담은 단어는 ‘우산’입니다.",
+ "sha256": "8d3e8e8bc97e5fe3f3362184c4abb12f01a882a1f1174d2644f0f70aac677c0a",
+ "bytes": 192428,
+ "duration": 4.008,
+ "rms": 0.09999996793233332,
+ "peak": 0.385650634765625
+ },
+ {
+ "file": "luck-market.wav",
+ "text": "오늘의 행운을 담은 단어는 ‘시장’입니다.",
+ "sha256": "356af25332bf2000ed349671f3ff39767929e1f4599af622c021f907d11fb7bf",
+ "bytes": 194732,
+ "duration": 4.056,
+ "rms": 0.09999998683675566,
+ "peak": 0.4022216796875
+ },
+ {
+ "file": "luck-oil.wav",
+ "text": "오늘의 행운을 담은 단어는 ‘기름’입니다.",
+ "sha256": "3fa1e662a3af8f2726b17b2baa5bec2934406e6595ebc243c8380dd16e190977",
+ "bytes": 190124,
+ "duration": 3.96,
+ "rms": 0.09999999431198445,
+ "peak": 0.3817138671875
+ },
+ {
+ "file": "luck-sound.wav",
+ "text": "오늘의 행운을 담은 단어는 ‘소리’입니다.",
+ "sha256": "34e4d9758d2ed448646fda364610e34779c84794403d9051ab3d8c1d33e2c80c",
+ "bytes": 184364,
+ "duration": 3.84,
+ "rms": 0.10000003234489044,
+ "peak": 0.386322021484375
+ },
+ {
+ "file": "luck-meat.wav",
+ "text": "오늘의 행운을 담은 단어는 ‘고기’입니다.",
+ "sha256": "d36dfc395a5c53fbfa4abe9980db7bb59adfdac59e40318c4aca365365e3f2ab",
+ "bytes": 187820,
+ "duration": 3.912,
+ "rms": 0.0999999898733683,
+ "peak": 0.371124267578125
+ },
+ {
+ "file": "luck-furniture.wav",
+ "text": "오늘의 행운을 담은 단어는 ‘가구’입니다.",
+ "sha256": "52cb92cdb8ee92b6f581c9d68dbc346f180c470564809ba57185429c9eb84127",
+ "bytes": 186668,
+ "duration": 3.888,
+ "rms": 0.10000004096194225,
+ "peak": 0.387054443359375
+ },
+ {
+ "file": "password-apple.wav",
+ "text": "연습 암호는 ‘다과’, 진짜 암호는 ‘사과’입니다.",
+ "sha256": "b6f5de3fe571fe3a7fdb722fe49250bcfeedef49475271e0ec76a7630907edd5",
+ "bytes": 235052,
+ "duration": 4.896,
+ "rms": 0.09999999690557956,
+ "peak": 0.483428955078125
+ },
+ {
+ "file": "password-sea.wav",
+ "text": "연습 암호는 ‘사다’, 진짜 암호는 ‘바다’입니다.",
+ "sha256": "03f45b8cb5b3dc72faf5c6e3d0e2a10cea6b60301ea400d0a4453596d2f0b337",
+ "bytes": 232748,
+ "duration": 4.848,
+ "rms": 0.09999999075635248,
+ "peak": 0.533905029296875
+ },
+ {
+ "file": "password-tree.wav",
+ "text": "연습 암호는 ‘나루’, 진짜 암호는 ‘나무’입니다.",
+ "sha256": "67da8147296e35134ec254bc82b58317630061b2c64d2d63c4be197ce8692d50",
+ "bytes": 233900,
+ "duration": 4.872,
+ "rms": 0.10000002225030123,
+ "peak": 0.490386962890625
+ },
+ {
+ "file": "password-hat.wav",
+ "text": "연습 암호는 ‘포자’, 진짜 암호는 ‘모자’입니다.",
+ "sha256": "e91bfc97df90a1675b6cd856880ac15c54de32cfc3b23603bdd1c2c5ac3ce6b8",
+ "bytes": 235052,
+ "duration": 4.896,
+ "rms": 0.10000000521661735,
+ "peak": 0.505889892578125
+ },
+ {
+ "file": "password-umbrella.wav",
+ "text": "연습 암호는 ‘부산’, 진짜 암호는 ‘우산’입니다.",
+ "sha256": "ab3ca6398c879971dc793eb25b472fcc0db72d76e386f11ef4d32c639f37c69d",
+ "bytes": 239660,
+ "duration": 4.992,
+ "rms": 0.10000000645104105,
+ "peak": 0.4776611328125
+ },
+ {
+ "file": "password-market.wav",
+ "text": "연습 암호는 ‘지장’, 진짜 암호는 ‘시장’입니다.",
+ "sha256": "02e325fb753ef7a3ee585a03d2ca2aa3f1fbc58523b062222ae1136eeed7a95c",
+ "bytes": 230444,
+ "duration": 4.8,
+ "rms": 0.10000002623625086,
+ "peak": 0.481689453125
+ },
+ {
+ "file": "password-oil.wav",
+ "text": "연습 암호는 ‘이름’, 진짜 암호는 ‘기름’입니다.",
+ "sha256": "b472957b27ff98ea7b8894fa1ff12653f7e9f66f77870faa9e52a1c1bf116c41",
+ "bytes": 240812,
+ "duration": 5.016,
+ "rms": 0.10000000204728039,
+ "peak": 0.4945068359375
+ },
+ {
+ "file": "password-sound.wav",
+ "text": "연습 암호는 ‘오리’, 진짜 암호는 ‘소리’입니다.",
+ "sha256": "044aafff08a19e39f21e58b963644715a987cd155896998769ef8e9ce6449628",
+ "bytes": 236204,
+ "duration": 4.92,
+ "rms": 0.10000000001020605,
+ "peak": 0.46209716796875
+ },
+ {
+ "file": "password-meat.wav",
+ "text": "연습 암호는 ‘보기’, 진짜 암호는 ‘고기’입니다.",
+ "sha256": "b009caadd56350d6a641ff17f0973e2b2724c183e1f45ae0bf3c483e237fa99e",
+ "bytes": 240812,
+ "duration": 5.016,
+ "rms": 0.10000000456206941,
+ "peak": 0.49786376953125
+ },
+ {
+ "file": "password-furniture.wav",
+ "text": "연습 암호는 ‘하구’, 진짜 암호는 ‘가구’입니다.",
+ "sha256": "fb2b631dd4e12120d08a84cce6450f9cd9cd68105d13d7001d808e7deaa1df4a",
+ "bytes": 233900,
+ "duration": 4.872,
+ "rms": 0.10000001899065697,
+ "peak": 0.483642578125
+ },
+ {
+ "file": "remember-apple.wav",
+ "text": "‘다과’. 이 단어는 잊으세요. 이제 ‘사과’, 이 단어를 기억하세요.",
+ "sha256": "1bf2aef9a9f030af1a8fd7dee8f44aa420a397b3ba99b575feeb3dc7a63ec191",
+ "bytes": 395180,
+ "duration": 8.232,
+ "rms": 0.10000000668138131,
+ "peak": 0.563568115234375
+ },
+ {
+ "file": "remember-sea.wav",
+ "text": "‘사다’. 이 단어는 잊으세요. 이제 ‘바다’, 이 단어를 기억하세요.",
+ "sha256": "5c91a36496e274d2be9c6fa8d9b506cd04f2a1b7ceb05ed1b7bb03fe7fca99d9",
+ "bytes": 399788,
+ "duration": 8.328,
+ "rms": 0.09999999180525658,
+ "peak": 0.546539306640625
+ },
+ {
+ "file": "remember-tree.wav",
+ "text": "‘나루’. 이 단어는 잊으세요. 이제 ‘나무’, 이 단어를 기억하세요.",
+ "sha256": "bc8e54f5407e8866bf1ca1ddfd4f6c63d85fee29bd9e430c7dd33e87974fc5b8",
+ "bytes": 409004,
+ "duration": 8.52,
+ "rms": 0.09999998112848245,
+ "peak": 0.54864501953125
+ },
+ {
+ "file": "remember-hat.wav",
+ "text": "‘포자’. 이 단어는 잊으세요. 이제 ‘모자’, 이 단어를 기억하세요.",
+ "sha256": "31a6a4270bf368cda419094d87a303ac3cd5c22539786cb165f8044b0b8e9d6e",
+ "bytes": 402092,
+ "duration": 8.376,
+ "rms": 0.09999997909968193,
+ "peak": 0.541778564453125
+ },
+ {
+ "file": "remember-umbrella.wav",
+ "text": "‘부산’. 이 단어는 잊으세요. 이제 ‘우산’, 이 단어를 기억하세요.",
+ "sha256": "c8988e604ba05d52597e561389541e8f9cda86e052279f1862bbf8399ead314e",
+ "bytes": 403244,
+ "duration": 8.4,
+ "rms": 0.10000001748273872,
+ "peak": 0.557830810546875
+ },
+ {
+ "file": "remember-market.wav",
+ "text": "‘지장’. 이 단어는 잊으세요. 이제 ‘시장’, 이 단어를 기억하세요.",
+ "sha256": "d89792da51c83ac49d27e55aa6d822a338899a5ee487b34b552748201e1522f6",
+ "bytes": 398636,
+ "duration": 8.304,
+ "rms": 0.09999999983200249,
+ "peak": 0.57281494140625
+ },
+ {
+ "file": "remember-oil.wav",
+ "text": "‘이름’. 이 단어는 잊으세요. 이제 ‘기름’, 이 단어를 기억하세요.",
+ "sha256": "3ca98682ff06d1d6b8af02c0fd5b2124f42266ac989884223eff4a9bbde1287a",
+ "bytes": 399788,
+ "duration": 8.328,
+ "rms": 0.09999999241389293,
+ "peak": 0.524383544921875
+ },
+ {
+ "file": "remember-sound.wav",
+ "text": "‘오리’. 이 단어는 잊으세요. 이제 ‘소리’, 이 단어를 기억하세요.",
+ "sha256": "98387ccc7aab566f6487d4c250b2128082cc527e013a6f7b7bc4d0266ac295df",
+ "bytes": 394028,
+ "duration": 8.208,
+ "rms": 0.10000002803973357,
+ "peak": 0.53778076171875
+ },
+ {
+ "file": "remember-meat.wav",
+ "text": "‘보기’. 이 단어는 잊으세요. 이제 ‘고기’, 이 단어를 기억하세요.",
+ "sha256": "9183ef90b64f5ceaf3bb0178c689231ff3e9981d6ce889124a15f2a6e2b7a344",
+ "bytes": 395180,
+ "duration": 8.232,
+ "rms": 0.10000003134220614,
+ "peak": 0.519287109375
+ },
+ {
+ "file": "remember-furniture.wav",
+ "text": "‘하구’. 이 단어는 잊으세요. 이제 ‘가구’, 이 단어를 기억하세요.",
+ "sha256": "442bdcb956f120c270e1f321feae0415c897de0183e468980fcc119b898b2a67",
+ "bytes": 403244,
+ "duration": 8.4,
+ "rms": 0.10000001858461878,
+ "peak": 0.5618896484375
+ },
+ {
+ "file": "last-apple.wav",
+ "text": "세 단어 중 마지막을 고르세요. ‘다과’, ‘사고’, ‘사과’.",
+ "sha256": "4cb2a8579fa3a7b91bb36363826ce8ca0d6d86e96e16a479e226a2802edd82f7",
+ "bytes": 356012,
+ "duration": 7.416,
+ "rms": 0.10000001764132661,
+ "peak": 0.622528076171875
+ },
+ {
+ "file": "last-sea.wav",
+ "text": "세 단어 중 마지막을 고르세요. ‘사다’, ‘보다’, ‘바다’.",
+ "sha256": "f755452e1da44674aa1df7d12edffa988091b89a394e7eb6d4a00eb721f07cbf",
+ "bytes": 345644,
+ "duration": 7.2,
+ "rms": 0.09999998238433916,
+ "peak": 0.591033935546875
+ },
+ {
+ "file": "last-tree.wav",
+ "text": "세 단어 중 마지막을 고르세요. ‘나루’, ‘너무’, ‘나무’.",
+ "sha256": "589e56d940a3c9e80ec02ae0b2dd93463fdc85ef019a753fcd0fded0752055f9",
+ "bytes": 382508,
+ "duration": 7.968,
+ "rms": 0.09999999432249608,
+ "peak": 0.58074951171875
+ },
+ {
+ "file": "last-hat.wav",
+ "text": "세 단어 중 마지막을 고르세요. ‘포자’, ‘모조’, ‘모자’.",
+ "sha256": "bdaf8fbc54df8061e9e58aa8c14b3044a6e863dc54973ee3ee6ae2c5ae1a28f1",
+ "bytes": 354860,
+ "duration": 7.392,
+ "rms": 0.099999998595968,
+ "peak": 0.550079345703125
+ },
+ {
+ "file": "last-umbrella.wav",
+ "text": "세 단어 중 마지막을 고르세요. ‘부산’, ‘오산’, ‘우산’.",
+ "sha256": "c801293a34013a8d93d3289a2cc87cd3eed83f97ba4647f5060efa00b117a9ad",
+ "bytes": 370988,
+ "duration": 7.728,
+ "rms": 0.09999998351512711,
+ "peak": 0.59765625
+ },
+ {
+ "file": "last-market.wav",
+ "text": "세 단어 중 마지막을 고르세요. ‘지장’, ‘사장’, ‘시장’.",
+ "sha256": "7857e87e9dbfe8fe849cc0b18c1d45b60916cb337328099c7a87a42c82e2821e",
+ "bytes": 359468,
+ "duration": 7.488,
+ "rms": 0.09999999842960891,
+ "peak": 0.600555419921875
+ },
+ {
+ "file": "last-oil.wav",
+ "text": "세 단어 중 마지막을 고르세요. ‘이름’, ‘거름’, ‘기름’.",
+ "sha256": "b5ce34950b8b31b1baf6ce55410797f0d8aa5478a670d6eace87cf76d134d6d3",
+ "bytes": 337580,
+ "duration": 7.032,
+ "rms": 0.10000002299391843,
+ "peak": 0.55706787109375
+ },
+ {
+ "file": "last-sound.wav",
+ "text": "세 단어 중 마지막을 고르세요. ‘오리’, ‘수리’, ‘소리’.",
+ "sha256": "ec9c49802aebeb6a8b898a1c0d8ad1daa127d2d64c905c197ed49a8e0c4eb64a",
+ "bytes": 354860,
+ "duration": 7.392,
+ "rms": 0.10000004316064788,
+ "peak": 0.5640869140625
+ },
+ {
+ "file": "last-meat.wav",
+ "text": "세 단어 중 마지막을 고르세요. ‘보기’, ‘고개’, ‘고기’.",
+ "sha256": "10f8ad4259ed8945c9da831e80b3902303c09caef96d400298599168fb1efed4",
+ "bytes": 346796,
+ "duration": 7.224,
+ "rms": 0.10000002519116054,
+ "peak": 0.537200927734375
+ },
+ {
+ "file": "last-furniture.wav",
+ "text": "세 단어 중 마지막을 고르세요. ‘하구’, ‘가교’, ‘가구’.",
+ "sha256": "5ad4f776e35439d408abed0e66567d6b5cdc80fc54d579165472e474f1238776",
+ "bytes": 357164,
+ "duration": 7.44,
+ "rms": 0.10000003394798516,
+ "peak": 0.583587646484375
+ },
+ {
+ "file": "first-apple.wav",
+ "text": "세 단어 중 첫 번째를 고르세요. ‘사과’, ‘다과’, ‘사고’.",
+ "sha256": "7c1dbcb3082055df2b8f087f7e906ec867c92f679de0894c34740c05e61a4bc1",
+ "bytes": 359468,
+ "duration": 7.488,
+ "rms": 0.1000000290826337,
+ "peak": 0.57977294921875
+ },
+ {
+ "file": "first-sea.wav",
+ "text": "세 단어 중 첫 번째를 고르세요. ‘바다’, ‘사다’, ‘보다’.",
+ "sha256": "0f0b39314c5f7d7168d4854003c0c284e7bc3438cdc4d1d1c5adab7cb5ed30e5",
+ "bytes": 349100,
+ "duration": 7.272,
+ "rms": 0.10000001935816175,
+ "peak": 0.57177734375
+ },
+ {
+ "file": "first-tree.wav",
+ "text": "세 단어 중 첫 번째를 고르세요. ‘나무’, ‘나루’, ‘너무’.",
+ "sha256": "3178230fd5850b6277a2153b438ec5289720e3db7ac929914cc213964f004bd5",
+ "bytes": 379052,
+ "duration": 7.896,
+ "rms": 0.09999998692384372,
+ "peak": 0.583343505859375
+ },
+ {
+ "file": "first-hat.wav",
+ "text": "세 단어 중 첫 번째를 고르세요. ‘모자’, ‘포자’, ‘모조’.",
+ "sha256": "17c8cde90567e7574b0c0702663bd70713054089ee920241b5444d9b7869eef5",
+ "bytes": 357164,
+ "duration": 7.44,
+ "rms": 0.1000000080433648,
+ "peak": 0.53125
+ },
+ {
+ "file": "first-umbrella.wav",
+ "text": "세 단어 중 첫 번째를 고르세요. ‘우산’, ‘부산’, ‘오산’.",
+ "sha256": "878e0bd2e6e4105f175aef51a028f6b141f3a2072be20f783193f3ba897a3ee0",
+ "bytes": 366380,
+ "duration": 7.632,
+ "rms": 0.10000000665263714,
+ "peak": 0.57000732421875
+ },
+ {
+ "file": "first-market.wav",
+ "text": "세 단어 중 첫 번째를 고르세요. ‘시장’, ‘지장’, ‘사장’.",
+ "sha256": "1e00b8f70a796cdebb0feb00c605fece7488b06915e6e036f54206f05e201e0c",
+ "bytes": 359468,
+ "duration": 7.488,
+ "rms": 0.10000001450369593,
+ "peak": 0.639068603515625
+ },
+ {
+ "file": "first-oil.wav",
+ "text": "세 단어 중 첫 번째를 고르세요. ‘기름’, ‘이름’, ‘거름’.",
+ "sha256": "f415195e0cfc7ee711d09de7519e88eab62cc8b58d70772c83c3f5c27c267819",
+ "bytes": 335276,
+ "duration": 6.984,
+ "rms": 0.10000000576279051,
+ "peak": 0.53472900390625
+ },
+ {
+ "file": "first-sound.wav",
+ "text": "세 단어 중 첫 번째를 고르세요. ‘소리’, ‘오리’, ‘수리’.",
+ "sha256": "46f6adeed928a16af0700f1e8626cf8049f124632a04a5d10604426fb06f9596",
+ "bytes": 353708,
+ "duration": 7.368,
+ "rms": 0.10000001446744461,
+ "peak": 0.518951416015625
+ },
+ {
+ "file": "first-meat.wav",
+ "text": "세 단어 중 첫 번째를 고르세요. ‘고기’, ‘보기’, ‘고개’.",
+ "sha256": "f51a37c1722fef581f7d642a4dcae7fcb9953b3c318dce971bf616cd1a81fecc",
+ "bytes": 345644,
+ "duration": 7.2,
+ "rms": 0.09999999288327781,
+ "peak": 0.51312255859375
+ },
+ {
+ "file": "first-furniture.wav",
+ "text": "세 단어 중 첫 번째를 고르세요. ‘가구’, ‘하구’, ‘가교’.",
+ "sha256": "29f007b385b7182db1d8aeccad4fc3c260df5e66e14e52a46b0ef0fcd0ff3a0a",
+ "bytes": 364076,
+ "duration": 7.584,
+ "rms": 0.09999997909045008,
+ "peak": 0.570037841796875
+ },
+ {
+ "file": "repeat-apple.wav",
+ "text": "두 번 들린 단어를 고르세요. ‘사과’, ‘다과’, ‘사과’, ‘사고’.",
+ "sha256": "cb7da4e9a1288cac64d168b241f27d7d2bc2dfecace58744f9bbf2792dfad04e",
+ "bytes": 397484,
+ "duration": 8.28,
+ "rms": 0.09999999222276726,
+ "peak": 0.641448974609375
+ },
+ {
+ "file": "repeat-sea.wav",
+ "text": "두 번 들린 단어를 고르세요. ‘바다’, ‘사다’, ‘바다’, ‘보다’.",
+ "sha256": "db2f235bee26ac804b5362e8e013b8f40c8da24fd3a34e0fb3b43c6d264feb36",
+ "bytes": 382508,
+ "duration": 7.968,
+ "rms": 0.10000003134647438,
+ "peak": 0.684326171875
+ },
+ {
+ "file": "repeat-tree.wav",
+ "text": "두 번 들린 단어를 고르세요. ‘나무’, ‘나루’, ‘나무’, ‘너무’.",
+ "sha256": "8735c3d9672561ca237fa87317beec2878492eb9063d6048d5b214423561acc1",
+ "bytes": 423980,
+ "duration": 8.832,
+ "rms": 0.10000000739222943,
+ "peak": 0.618743896484375
+ },
+ {
+ "file": "repeat-hat.wav",
+ "text": "두 번 들린 단어를 고르세요. ‘모자’, ‘포자’, ‘모자’, ‘모조’.",
+ "sha256": "6e089a42fd7e65e82eec7186d369c926fb7fdac794365b2bd7658822fd92437c",
+ "bytes": 396332,
+ "duration": 8.256,
+ "rms": 0.09999999984675931,
+ "peak": 0.616729736328125
+ },
+ {
+ "file": "repeat-umbrella.wav",
+ "text": "두 번 들린 단어를 고르세요. ‘우산’, ‘부산’, ‘우산’, ‘오산’.",
+ "sha256": "5e59ef39113c3d5e106862356d715f4cef2862ef8ee02654a97e532675c1ac6d",
+ "bytes": 410156,
+ "duration": 8.544,
+ "rms": 0.09999995863648034,
+ "peak": 0.674835205078125
+ },
+ {
+ "file": "repeat-market.wav",
+ "text": "두 번 들린 단어를 고르세요. ‘시장’, ‘지장’, ‘시장’, ‘사장’.",
+ "sha256": "68a5b899115a66ab225d610ec47b4de56cc8793657362bc38f533c06f741e4cc",
+ "bytes": 402092,
+ "duration": 8.376,
+ "rms": 0.09999998813966116,
+ "peak": 0.6822509765625
+ },
+ {
+ "file": "repeat-oil.wav",
+ "text": "두 번 들린 단어를 고르세요. ‘기름’, ‘이름’, ‘기름’, ‘거름’.",
+ "sha256": "55b2c7d6aa8bbce04edf8661882c9781da81c57f56217f99b80457a3bb895da0",
+ "bytes": 369836,
+ "duration": 7.704,
+ "rms": 0.0999999804882663,
+ "peak": 0.62396240234375
+ },
+ {
+ "file": "repeat-sound.wav",
+ "text": "두 번 들린 단어를 고르세요. ‘소리’, ‘오리’, ‘소리’, ‘수리’.",
+ "sha256": "e9d636ff7f4777f38b1ff15bebdcec6fbde95b4b4b05625a7415b4cfa0025b59",
+ "bytes": 395180,
+ "duration": 8.232,
+ "rms": 0.1000000073748717,
+ "peak": 0.6114501953125
+ },
+ {
+ "file": "repeat-meat.wav",
+ "text": "두 번 들린 단어를 고르세요. ‘고기’, ‘보기’, ‘고기’, ‘고개’.",
+ "sha256": "6599d2fac73ea616a793151b4ce6f51af673988d630bac6219a77e78ae6cb34e",
+ "bytes": 380204,
+ "duration": 7.92,
+ "rms": 0.10000001571010926,
+ "peak": 0.59210205078125
+ },
+ {
+ "file": "repeat-furniture.wav",
+ "text": "두 번 들린 단어를 고르세요. ‘가구’, ‘하구’, ‘가구’, ‘가교’.",
+ "sha256": "bf84dc6cff1f227924a3a62158d2fc18769fb0ca40b91418fa19e534dbf92f00",
+ "bytes": 397484,
+ "duration": 8.28,
+ "rms": 0.10000003132755503,
+ "peak": 0.667022705078125
+ },
+ {
+ "file": "correction-apple.wav",
+ "text": "정답은 ‘다과’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘사과’입니다.",
+ "sha256": "ccd7327a3fb54d3320d049ffdced3bc17595861cee69429c0ee97182d454195b",
+ "bytes": 402092,
+ "duration": 8.376,
+ "rms": 0.10000001654596266,
+ "peak": 0.490325927734375
+ },
+ {
+ "file": "correction-sea.wav",
+ "text": "정답은 ‘사다’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘바다’입니다.",
+ "sha256": "9233431d78ab4b144e3c33ad0cf0d49fc0fb91b29514343230ba32987b794bb3",
+ "bytes": 398636,
+ "duration": 8.304,
+ "rms": 0.09999997225238083,
+ "peak": 0.49615478515625
+ },
+ {
+ "file": "correction-tree.wav",
+ "text": "정답은 ‘나루’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘나무’입니다.",
+ "sha256": "b8e8bbe6b8b14727efd953fae9d4aed2caeb004055d52f611c6378322847edbe",
+ "bytes": 400940,
+ "duration": 8.352,
+ "rms": 0.10000003932190811,
+ "peak": 0.470611572265625
+ },
+ {
+ "file": "correction-hat.wav",
+ "text": "정답은 ‘포자’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘모자’입니다.",
+ "sha256": "f9f98c91cfbcfcb106eef7d5d3795a9cf3c3aab7e19a60f13d7dffe284d19f86",
+ "bytes": 398636,
+ "duration": 8.304,
+ "rms": 0.10000000019629104,
+ "peak": 0.470977783203125
+ },
+ {
+ "file": "correction-umbrella.wav",
+ "text": "정답은 ‘부산’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘우산’입니다.",
+ "sha256": "92b54f04884ab915783945077efd8f3bd443849a1720f72168b6348cae1f8e85",
+ "bytes": 406700,
+ "duration": 8.472,
+ "rms": 0.10000000137593418,
+ "peak": 0.479034423828125
+ },
+ {
+ "file": "correction-market.wav",
+ "text": "정답은 ‘지장’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘시장’입니다.",
+ "sha256": "b8d25b867497adcd0fe7c8b6a8a8d2e7a52965956738a83e53f58735708773f9",
+ "bytes": 405548,
+ "duration": 8.448,
+ "rms": 0.10000000759335076,
+ "peak": 0.491485595703125
+ },
+ {
+ "file": "correction-oil.wav",
+ "text": "정답은 ‘이름’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘기름’입니다.",
+ "sha256": "f6eff0cdddcad9790351491e60c53cb2a70d222033a583652f2de0b8cd6abcc7",
+ "bytes": 406700,
+ "duration": 8.472,
+ "rms": 0.09999998611167496,
+ "peak": 0.470794677734375
+ },
+ {
+ "file": "correction-sound.wav",
+ "text": "정답은 ‘오리’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘소리’입니다.",
+ "sha256": "5a603bc69979603afddcaeb6edf500a7c540b72405daeb33f5335d746b327da1",
+ "bytes": 397484,
+ "duration": 8.28,
+ "rms": 0.10000004224092506,
+ "peak": 0.469268798828125
+ },
+ {
+ "file": "correction-meat.wav",
+ "text": "정답은 ‘보기’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘고기’입니다.",
+ "sha256": "48e91b3932d3798e852041a854b3ee08c72127e092c62083a47af6b99adff241",
+ "bytes": 403244,
+ "duration": 8.4,
+ "rms": 0.09999999319342226,
+ "peak": 0.481536865234375
+ },
+ {
+ "file": "correction-furniture.wav",
+ "text": "정답은 ‘하구’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘가구’입니다.",
+ "sha256": "5c004a7b2f5a4a918c9f689f1a2dcb219a620ed14313f40a9655b36dbf64948d",
+ "bytes": 398636,
+ "duration": 8.304,
+ "rms": 0.10000003705538822,
+ "peak": 0.487396240234375
+ },
+ {
+ "file": "middle-apple.wav",
+ "text": "세 단어 중 가운데를 고르세요. ‘다과’, ‘사과’, ‘사고’.",
+ "sha256": "8832f1b793ab3eedbe663875ea8dbeabb122c329bd5879437c71230b0418c3c8",
+ "bytes": 360620,
+ "duration": 7.512,
+ "rms": 0.1000000102385111,
+ "peak": 0.553558349609375
+ },
+ {
+ "file": "middle-sea.wav",
+ "text": "세 단어 중 가운데를 고르세요. ‘사다’, ‘바다’, ‘보다’.",
+ "sha256": "b6dbe0af143ea775f05bd59a3d6af348bcc770ab378ac91ea75a56e06c326edf",
+ "bytes": 346796,
+ "duration": 7.224,
+ "rms": 0.1000000185368972,
+ "peak": 0.56494140625
+ },
+ {
+ "file": "middle-tree.wav",
+ "text": "세 단어 중 가운데를 고르세요. ‘나루’, ‘나무’, ‘너무’.",
+ "sha256": "341888c75275c13eb31058f53a1d4276be0daa0a1b5a3a45522036928b83b0c1",
+ "bytes": 382508,
+ "duration": 7.968,
+ "rms": 0.09999997157674806,
+ "peak": 0.600006103515625
+ },
+ {
+ "file": "middle-hat.wav",
+ "text": "세 단어 중 가운데를 고르세요. ‘포자’, ‘모자’, ‘모조’.",
+ "sha256": "b01ef9762ac84e3eb757243bc9286c62f7d45444a821ae35ac36a8b095e1598a",
+ "bytes": 353708,
+ "duration": 7.368,
+ "rms": 0.09999999400343942,
+ "peak": 0.555206298828125
+ },
+ {
+ "file": "middle-umbrella.wav",
+ "text": "세 단어 중 가운데를 고르세요. ‘부산’, ‘우산’, ‘오산’.",
+ "sha256": "399ea2f681f755918f1117c8baba42739ceca64b307b999342f457a9235050b6",
+ "bytes": 368684,
+ "duration": 7.68,
+ "rms": 0.10000004859108667,
+ "peak": 0.569549560546875
+ },
+ {
+ "file": "middle-market.wav",
+ "text": "세 단어 중 가운데를 고르세요. ‘지장’, ‘시장’, ‘사장’.",
+ "sha256": "c855b449e0d4ace74681bfe68e5e7f0b063568827ae872017234dbe8ae119e4c",
+ "bytes": 356012,
+ "duration": 7.416,
+ "rms": 0.10000000455585449,
+ "peak": 0.594970703125
+ },
+ {
+ "file": "middle-oil.wav",
+ "text": "세 단어 중 가운데를 고르세요. ‘이름’, ‘기름’, ‘거름’.",
+ "sha256": "7a6b122b11c07da86c95fb78e2db2a70681b5219c63d6441fe56da1979ac1dc3",
+ "bytes": 334124,
+ "duration": 6.96,
+ "rms": 0.10000001284837577,
+ "peak": 0.5399169921875
+ },
+ {
+ "file": "middle-sound.wav",
+ "text": "세 단어 중 가운데를 고르세요. ‘오리’, ‘소리’, ‘수리’.",
+ "sha256": "4d00aa6114327f450a00dacbc7e7b572ebf1de4ce9dc2fe4b5505766cca71ce9",
+ "bytes": 353708,
+ "duration": 7.368,
+ "rms": 0.09999999876572897,
+ "peak": 0.534820556640625
+ },
+ {
+ "file": "middle-meat.wav",
+ "text": "세 단어 중 가운데를 고르세요. ‘보기’, ‘고기’, ‘고개’.",
+ "sha256": "d416107cc05e7c5b92ca27100a10a8a9689de913346179945d5542de4a6bac0c",
+ "bytes": 342188,
+ "duration": 7.128,
+ "rms": 0.10000002241734321,
+ "peak": 0.510833740234375
+ },
+ {
+ "file": "middle-furniture.wav",
+ "text": "세 단어 중 가운데를 고르세요. ‘하구’, ‘가구’, ‘가교’.",
+ "sha256": "b45b1afc8d9b534ff3e0a2a2ecb99efc1f4e40d41f89f55c4ed8ba94ad6d76d4",
+ "bytes": 354860,
+ "duration": 7.392,
+ "rms": 0.10000000075274348,
+ "peak": 0.5576171875
+ },
+ {
+ "file": "message-apple.wav",
+ "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘다과’, ‘사과’. 지금은 빨간 불입니다.",
+ "sha256": "d85e6f47d871369f00de2274139e5ad6b4b5686b5e20e248df0ed6b9a320d942",
+ "bytes": 517292,
+ "duration": 10.776,
+ "rms": 0.10000001428889653,
+ "peak": 0.525360107421875
+ },
+ {
+ "file": "message-sea.wav",
+ "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘사다’, ‘바다’. 지금은 빨간 불입니다.",
+ "sha256": "28a461fbe03bffe2560030dea8b60715f7c0fe262dcf85b5b7f7daf483836382",
+ "bytes": 517292,
+ "duration": 10.776,
+ "rms": 0.09999998804256775,
+ "peak": 0.53521728515625
+ },
+ {
+ "file": "message-tree.wav",
+ "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘나루’, ‘나무’. 지금은 빨간 불입니다.",
+ "sha256": "5d684aa710a63d36ed8dcc2a8bfbdd5c71b0b1c63dad2a324adecb76591e85b8",
+ "bytes": 540332,
+ "duration": 11.256,
+ "rms": 0.09999996112285715,
+ "peak": 0.5262451171875
+ },
+ {
+ "file": "message-hat.wav",
+ "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘포자’, ‘모자’. 지금은 빨간 불입니다.",
+ "sha256": "5a713a8522b231bb8276805c014b385b9150b79f58ff2618446607e2b442495e",
+ "bytes": 518444,
+ "duration": 10.8,
+ "rms": 0.10000001032745777,
+ "peak": 0.587005615234375
+ },
+ {
+ "file": "message-umbrella.wav",
+ "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘부산’, ‘우산’. 지금은 빨간 불입니다.",
+ "sha256": "41d5866dae66600df4694a8312b531571d44cece6dba0e423e7c43d09d184819",
+ "bytes": 523052,
+ "duration": 10.896,
+ "rms": 0.09999985338851038,
+ "peak": 0.530364990234375
+ },
+ {
+ "file": "message-market.wav",
+ "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘지장’, ‘시장’. 지금은 빨간 불입니다.",
+ "sha256": "9242d4cd6e0030e6b073e4d2360f84aa8a454d1749711cdb2d64c14f28a7ea20",
+ "bytes": 523052,
+ "duration": 10.896,
+ "rms": 0.10000000012409799,
+ "peak": 0.534820556640625
+ },
+ {
+ "file": "message-oil.wav",
+ "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘이름’, ‘기름’. 지금은 빨간 불입니다.",
+ "sha256": "a2a71a7e5e07c81871a25c6cb7540c39aa0b32fc44f78cda66b5f34ace98d805",
+ "bytes": 509228,
+ "duration": 10.608,
+ "rms": 0.09999999520245731,
+ "peak": 0.51812744140625
+ },
+ {
+ "file": "message-sound.wav",
+ "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘오리’, ‘소리’. 지금은 빨간 불입니다.",
+ "sha256": "1e682147dae8026ba78dc8f256c7f24acb3a6743bfd88a0fcc86096930eef078",
+ "bytes": 521900,
+ "duration": 10.872,
+ "rms": 0.09999998164749332,
+ "peak": 0.51953125
+ },
+ {
+ "file": "message-meat.wav",
+ "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘보기’, ‘고기’. 지금은 빨간 불입니다.",
+ "sha256": "ce19735df079b54c171e775b6b365385a80160b9033442abab95b2b9c8505e1a",
+ "bytes": 512684,
+ "duration": 10.68,
+ "rms": 0.10000001752061703,
+ "peak": 0.5147705078125
+ },
+ {
+ "file": "message-furniture.wav",
+ "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘하구’, ‘가구’. 지금은 빨간 불입니다.",
+ "sha256": "26c331b7e12311236f4bb48f88918dfbf53cccd2f50b55741ea6ff4473e71dd8",
+ "bytes": 521900,
+ "duration": 10.872,
+ "rms": 0.09999998693144198,
+ "peak": 0.529327392578125
+ },
+ {
+ "file": "mission-apple.wav",
+ "text": "오른쪽 표지는 ‘사과’, 왼쪽 표지는 ‘다과’입니다.",
+ "sha256": "3ef42e276ff3500c3e197d149f29f79d6a0c1d5516ed1d5a2c57210333fded9c",
+ "bytes": 246572,
+ "duration": 5.136,
+ "rms": 0.10000000281248776,
+ "peak": 0.47015380859375
+ },
+ {
+ "file": "mission-sea.wav",
+ "text": "오른쪽 표지는 ‘바다’, 왼쪽 표지는 ‘사다’입니다.",
+ "sha256": "8838d6a1ee8a790427dc5aa761e70b3d8872af62086efeed743297ff7f8b8a06",
+ "bytes": 243116,
+ "duration": 5.064,
+ "rms": 0.10000001881351578,
+ "peak": 0.45013427734375
+ },
+ {
+ "file": "mission-tree.wav",
+ "text": "오른쪽 표지는 ‘나무’, 왼쪽 표지는 ‘나루’입니다.",
+ "sha256": "00d8e77ec0371971d632724d91f8cf8d947bfb83c582a513b6421be4c457a1da",
+ "bytes": 241964,
+ "duration": 5.04,
+ "rms": 0.09999999375024479,
+ "peak": 0.458526611328125
+ },
+ {
+ "file": "mission-hat.wav",
+ "text": "오른쪽 표지는 ‘모자’, 왼쪽 표지는 ‘포자’입니다.",
+ "sha256": "308278ec2bd27d4224976a55632d1f7fa1edabb0792126d9d858a699c287aee4",
+ "bytes": 245420,
+ "duration": 5.112,
+ "rms": 0.10000000114877691,
+ "peak": 0.4498291015625
+ },
+ {
+ "file": "mission-umbrella.wav",
+ "text": "오른쪽 표지는 ‘우산’, 왼쪽 표지는 ‘부산’입니다.",
+ "sha256": "4b4c858a25659540f9dbc524664496fc6de61ab8c1627f3cfee8c9c744e11d5b",
+ "bytes": 247724,
+ "duration": 5.16,
+ "rms": 0.1000000183428341,
+ "peak": 0.46533203125
+ },
+ {
+ "file": "mission-market.wav",
+ "text": "오른쪽 표지는 ‘시장’, 왼쪽 표지는 ‘지장’입니다.",
+ "sha256": "cefa0e95649ea6bd1df4c8dfccd2b74b4f4c028714420ce1087b61db2f2a8d47",
+ "bytes": 248876,
+ "duration": 5.184,
+ "rms": 0.10000000503014783,
+ "peak": 0.485687255859375
+ },
+ {
+ "file": "mission-oil.wav",
+ "text": "오른쪽 표지는 ‘기름’, 왼쪽 표지는 ‘이름’입니다.",
+ "sha256": "5fb63e04520c312aa6b7b48b70c2f7c140b5ab480e5c6992edbc2ba85b9a06e7",
+ "bytes": 241964,
+ "duration": 5.04,
+ "rms": 0.10000003066640888,
+ "peak": 0.443572998046875
+ },
+ {
+ "file": "mission-sound.wav",
+ "text": "오른쪽 표지는 ‘소리’, 왼쪽 표지는 ‘오리’입니다.",
+ "sha256": "659eb0e2b06e6b165b46744769743e032e2282519f8486c57bc45e0f7d07a069",
+ "bytes": 240812,
+ "duration": 5.016,
+ "rms": 0.10000000508879303,
+ "peak": 0.454803466796875
+ },
+ {
+ "file": "mission-meat.wav",
+ "text": "오른쪽 표지는 ‘고기’, 왼쪽 표지는 ‘보기’입니다.",
+ "sha256": "94f8a4f9e116a787e5ed052661d6de9ecf3ad8629db476661dd59ad03014b0c9",
+ "bytes": 244268,
+ "duration": 5.088,
+ "rms": 0.0999999931549985,
+ "peak": 0.441864013671875
+ },
+ {
+ "file": "mission-furniture.wav",
+ "text": "오른쪽 표지는 ‘가구’, 왼쪽 표지는 ‘하구’입니다.",
+ "sha256": "af82e03b686635c3356e706fe5b05c1231aeaaeab33f275aa5b8714988c65d43",
+ "bytes": 241964,
+ "duration": 5.04,
+ "rms": 0.09999996810995776,
+ "peak": 0.4852294921875
+ }
+ ]
+ }
+
❯ tests/neural-release.test.ts:49:51
47| it("현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다", () => {
48| for (const path of ["docs/ARCHITECTURE.md", "docs/DEPLOYMENT.md", "d…
49| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
| ^
50| const history = readFileSync("CHANGELOG.md", "utf8");
51| for (const version of ["0.2.3", "0.1.1", "0.1.0"]) expect(history).t…
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[17/20]⎯
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다
AssertionError: src/audio/assets.ts: expected '// WAV를 같은 파일명으로 교체하면 장기 캐시가 남는다. 음성 …' to contain '0.2.3'
- Expected
+ Received
- 0.2.3
+ // WAV를 같은 파일명으로 교체하면 장기 캐시가 남는다. 음성 내용이 바뀔 때
+ // 이 자산 버전도 갱신한다. 제품 버전과 별개로 관리하며 intro·목표에 함께 쓴다.
+ export const AUDIO_ASSET_VERSION = "0.1.1";
+
+ export function audioAssetUrl(file: string): string {
+ return `/audio/${file}?v=${AUDIO_ASSET_VERSION}`;
+ }
+
+ // 배경 녹음도 목표 WAV와 같은 자산 버전으로 캐시를 갱신한다.
+ export function ambienceAssetUrl(file: string): string {
+ return `/ambience/${file}?v=${AUDIO_ASSET_VERSION}`;
+ }
+
❯ tests/neural-release.test.ts:49:51
47| it("현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다", () => {
48| for (const path of ["docs/ARCHITECTURE.md", "docs/DEPLOYMENT.md", "d…
49| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
| ^
50| const history = readFileSync("CHANGELOG.md", "utf8");
51| for (const version of ["0.2.3", "0.1.1", "0.1.0"]) expect(history).t…
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[18/20]⎯
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다
Error: ENOENT: no such file or directory, open 'CHANGELOG.md'
❯ tests/neural-release.test.ts:50:19
48| for (const path of ["docs/ARCHITECTURE.md", "docs/DEPLOYMENT.md", "d…
49| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
50| const history = readFileSync("CHANGELOG.md", "utf8");
| ^
51| for (const version of ["0.2.3", "0.1.1", "0.1.0"]) expect(history).t…
52| for (const change of ["SNR", "3문항", "중도 종료", "CC BY-SA", "Chrome", "…
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[19/20]⎯
Test Files 5 failed (5)
Tests 8 failed | 42 passed (50)
Start at 08:04:54
Duration 5.05s (tests 51%, environment 34%, setup 9%, transform 3%, import 2%, worker 1%)
Environment jsdom was created 5 times · 2.91s total, 34% of tracked time
create it once per worker with pool: 'vmThreads' (keeps per-file isolation) or isolate: false (shares it across files)
learn more: https://vitest.dev/guide/improving-performance#test-environments