3258 lines
159 KiB
Plaintext
3258 lines
159 KiB
Plaintext
|
||
RUN v5.0.4 /home/hermes/worktrees/find-word-v0.2.5
|
||
|
||
❯ tests/assets.test.ts (2 tests | 1 failed) 429ms
|
||
❯ 저장한 한국어 neural 음성 자산 (2)
|
||
× 0.2.5 neural provider·voice·prosody와 실측 도구 버전을 기록한다 16ms
|
||
❯ tests/neural-release.test.ts (4 tests | 3 failed) 100ms
|
||
× 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다 75ms
|
||
× 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다 15ms
|
||
× README는 현재 리뷰 증거 경로와 정확한 라운드 보너스를 안내한다 4ms
|
||
❯ tests/audio.test.ts (13 tests | 3 failed) 393ms
|
||
❯ 오디오 재생 수명과 믹서 (8)
|
||
× intro와 목표 실제 fetch URL에 같은 명시적 cache-busting 버전을 붙인다 59ms
|
||
× intro 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다 35ms
|
||
× target 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다 61ms
|
||
❯ tests/App.test.tsx (23 tests | 1 failed) 4544ms
|
||
❯ 시작부터 결과까지 사용자 흐름 (13)
|
||
× 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다 53ms
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 8 ⎯⎯⎯⎯⎯⎯⎯
|
||
|
||
FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다
|
||
TestingLibraryElementError: Unable to find an element with the text: v0.2.5. 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
|
||
[36m<body>[39m
|
||
[36m<div>[39m
|
||
[36m<a[39m
|
||
[33mclass[39m=[32m"skip-link"[39m
|
||
[33mhref[39m=[32m"#main"[39m
|
||
[36m>[39m
|
||
[0m본문으로 건너뛰기[0m
|
||
[36m</a>[39m
|
||
[36m<header[39m
|
||
[33mclass[39m=[32m"header wrap"[39m
|
||
[36m>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"brand"[39m
|
||
[36m>[39m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"brand-mark"[39m
|
||
[36m>[39m
|
||
[36m<i />[39m
|
||
[36m<i />[39m
|
||
[36m<i />[39m
|
||
[36m<i />[39m
|
||
[36m</span>[39m
|
||
[36m<h1>[39m
|
||
[0m단어를 맞춰라[0m
|
||
[36m</h1>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"version"[39m
|
||
[36m>[39m
|
||
[0mv0.2.3[0m
|
||
[36m</span>[39m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"header-actions"[39m
|
||
[36m>[39m
|
||
[36m<a[39m
|
||
[33mhref[39m=[32m"#howto"[39m
|
||
[36m>[39m
|
||
[0m플레이 방법 [0m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[36m>[39m
|
||
[0m↓[0m
|
||
[36m</span>[39m
|
||
[36m</a>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"little-dot"[39m
|
||
[36m/>[39m
|
||
[0m소리에 집중하는 시간[0m
|
||
[36m</div>[39m
|
||
[36m</header>[39m
|
||
[36m<main[39m
|
||
[33mclass[39m=[32m"wrap"[39m
|
||
[33mid[39m=[32m"main"[39m
|
||
[33mtabindex[39m=[32m"-1"[39m
|
||
[36m>[39m
|
||
[36m<section[39m
|
||
[33maria-labelledby[39m=[32m"hero-title"[39m
|
||
[33mclass[39m=[32m"hero"[39m
|
||
[36m>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"hero-copy"[39m
|
||
[36m>[39m
|
||
[36m<p[39m
|
||
[33mclass[39m=[32m"eyebrow"[39m
|
||
[36m>[39m
|
||
[36m<span />[39m
|
||
[0m 귀를 기울이면, 발견하는 즐거움[0m
|
||
[36m</p>[39m
|
||
[36m<h2[39m
|
||
[33mid[39m=[32m"hero-title"[39m
|
||
[36m>[39m
|
||
[0m소음 속 작은 단어,[0m
|
||
[36m<br />[39m
|
||
[36m<em>[39m
|
||
[0m들리시나요?[0m
|
||
[36m</em>[39m
|
||
[36m</h2>[39m
|
||
[36m<p[39m
|
||
[33mclass[39m=[32m"lead"[39m
|
||
[36m>[39m
|
||
[0m도시의 소리 사이에 에스가 숨겨 둔 단어.[0m
|
||
[36m<br />[39m
|
||
[0m조금 더 귀 기울여, 오늘의 행운을 찾아보세요.[0m
|
||
[36m</p>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"hero-meta"[39m
|
||
[36m>[39m
|
||
[36m<span>[39m
|
||
[0m한 라운드 [0m
|
||
[0m3[0m
|
||
[0m개의 질문[0m
|
||
[36m</span>[39m
|
||
[36m<span>[39m
|
||
[0m5단계 도전[0m
|
||
[36m</span>[39m
|
||
[36m<span>[39m
|
||
[0m나만의 속도로[0m
|
||
[36m</span>[39m
|
||
[36m</div>[39m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"sound-room"[39m
|
||
[36m>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"room-top"[39m
|
||
[36m>[39m
|
||
[36m<span>[39m
|
||
[36m<i />[39m
|
||
[0m 에스의 사운드 룸[0m
|
||
[36m</span>[39m
|
||
[36m<span>[39m
|
||
[0m8[0m
|
||
[0m가지 환경[0m
|
||
[36m</span>[39m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"orbit orbit-one"[39m
|
||
[36m/>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"orbit orbit-two"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"floating-note note-one"[39m
|
||
[36m>[39m
|
||
[0m♪[0m
|
||
[36m</span>[39m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"floating-note note-two"[39m
|
||
[36m>[39m
|
||
[0m✳[0m
|
||
[36m</span>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"speech-bubble"[39m
|
||
[36m>[39m
|
||
[0m행운의 단어를 찾아보거라![0m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"mascot "[39m
|
||
[36m>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"ear left"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"ear right"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"eye left"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"eye right"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"smile"[39m
|
||
[36m/>[39m
|
||
[36m<b>[39m
|
||
[0mS[0m
|
||
[36m</b>[39m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"room-bottom"[39m
|
||
[36m>[39m
|
||
[36m<div[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"sound-wave "[39m
|
||
[36m>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 10px; animation-delay: 0s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 33.799955450859244px; animation-delay: 0.05s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 16.132986448643948px; animation-delay: 0.1s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 32.219552375865575px; animation-delay: 0.15000000000000002s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 21.858720427326595px; animation-delay: 0.2s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 29.163690702963766px; animation-delay: 0.25s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 26.796992502245015px; animation-delay: 0.30000000000000004s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 24.835290693688798px; animation-delay: 0.35000000000000003s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 30.619883556555898px; animation-delay: 0.4s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 35.39139668035928px; animation-delay: 0.45s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 71.52943948029164px; animation-delay: 0.5s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"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.5")).toBeInTheDocument();
|
||
| ^
|
||
207| expect(f.audio.play).not.toHaveBeenCalled();
|
||
208| expect(
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/20]⎯
|
||
|
||
FAIL tests/assets.test.ts > 저장한 한국어 neural 음성 자산 > 0.2.5 neural provider·voice·prosody와 실측 도구 버전을 기록한다
|
||
AssertionError: expected '0.2.3' to be '0.2.5' // Object.is equality
|
||
|
||
Expected: "0.2.5"
|
||
Received: "0.2.3"
|
||
|
||
❯ tests/assets.test.ts:39:35
|
||
37| describe("저장한 한국어 neural 음성 자산", () => {
|
||
38| it("0.2.5 neural provider·voice·prosody와 실측 도구 버전을 기록한다", () => {
|
||
39| expect.soft(manifest.version).toBe("0.2.5");
|
||
| ^
|
||
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.2.3', …(2) ] to deeply equal [ '/audio/intro.wav?v=0.2.5', …(2) ]
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
[
|
||
- "/audio/intro.wav?v=0.2.5",
|
||
- "/audio/repeat-market.wav?v=0.2.5",
|
||
+ "/audio/intro.wav?v=0.2.3",
|
||
+ "/audio/repeat-market.wav?v=0.2.3",
|
||
"/ambience/cafe.wav?v=0.2.3",
|
||
]
|
||
|
||
❯ tests/audio.test.ts:99:54
|
||
97| f.sources.at(-1)!.onended?.();
|
||
98| await done;
|
||
99| expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||
| ^
|
||
100| "/audio/intro.wav?v=0.2.5",
|
||
101| `/audio/${question.id}.wav?v=0.2.5`,
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[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.2.3', …(5) ] to deeply equal [ '/audio/intro.wav?v=0.2.5', …(5) ]
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
[
|
||
- "/audio/intro.wav?v=0.2.5",
|
||
- "/audio/repeat-market.wav?v=0.2.5",
|
||
+ "/audio/intro.wav?v=0.2.3",
|
||
+ "/audio/repeat-market.wav?v=0.2.3",
|
||
"/ambience/cafe.wav?v=0.2.3",
|
||
- "/audio/intro.wav?v=0.2.5",
|
||
- "/audio/repeat-market.wav?v=0.2.5",
|
||
+ "/audio/intro.wav?v=0.2.3",
|
||
+ "/audio/repeat-market.wav?v=0.2.3",
|
||
"/ambience/cafe.wav?v=0.2.3",
|
||
]
|
||
|
||
❯ tests/audio.test.ts:133:56
|
||
131| f.sources.at(-1)!.onended?.();
|
||
132| await done;
|
||
133| expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||
| ^
|
||
134| "/audio/intro.wav?v=0.2.5",
|
||
135| `/audio/${question.id}.wav?v=0.2.5`,
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: expected '0.2.3' to be '0.2.5' // Object.is equality
|
||
|
||
Expected: "0.2.5"
|
||
Received: "0.2.3"
|
||
|
||
❯ 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.5");
|
||
| ^
|
||
10| expect.soft(lock.version).toBe("0.2.5");
|
||
11| expect.soft(lock.packages[""].version).toBe("0.2.5");
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: expected '0.2.3' to be '0.2.5' // Object.is equality
|
||
|
||
Expected: "0.2.5"
|
||
Received: "0.2.3"
|
||
|
||
❯ 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.5");
|
||
10| expect.soft(lock.version).toBe("0.2.5");
|
||
| ^
|
||
11| expect.soft(lock.packages[""].version).toBe("0.2.5");
|
||
12| for (const path of [
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: expected '0.2.3' to be '0.2.5' // Object.is equality
|
||
|
||
Expected: "0.2.5"
|
||
Received: "0.2.3"
|
||
|
||
❯ tests/neural-release.test.ts:11:42
|
||
9| expect.soft(pkg.version).toBe("0.2.5");
|
||
10| expect.soft(lock.version).toBe("0.2.5");
|
||
11| expect.soft(lock.packages[""].version).toBe("0.2.5");
|
||
| ^
|
||
12| for (const path of [
|
||
13| "src/App.tsx",
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: src/App.tsx: expected 'import { useEffect, useRef, useState …' to contain '0.2.5'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.2.5
|
||
+ 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("");
|
||
+ // 고정 seed의 테스트와 재도전 모두에서 새 문제 조합을 만들기 위한 라운드 번호다.
|
||
+ const [attempt, setAttempt] = useState(0);
|
||
+ // token은 오래된 완료를 무시하고 playing은 같은 클릭 흐름의 중복 재생을 막는다.
|
||
+ 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.2.3</span>
|
||
+ </div>
|
||
+ <div className="header-actions">
|
||
+ {game && game.phase !== "results" ? (
|
||
+ <button className="text-button" onClick={exitRound}>
|
||
+ 문제 풀이 종료
|
||
+ </button>
|
||
+ ) : !game ? (
|
||
+ <a href="#howto">
|
||
+ 플레이 방법 <span aria-hidden="true">↓</span>
|
||
+ </a>
|
||
+ ) : null}
|
||
+ <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);
|
||
+ }}
|
||
+ >
|
||
+ {/* 모바일 선택값은 단계/이름만 표시하고 SNR은 바로 아래 제어에서 읽게 해 잘림을 막는다. */}
|
||
+ {LEVELS.map((level) => (
|
||
+ <option key={level.id} value={level.id}>
|
||
+ {level.id}단계 · {level.name}
|
||
+ </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
|
||
+ ? "문제 풀이를 중간에 종료했어요."
|
||
+ : `${question!.level}단계 문제 풀이를 마쳤어요.`}
|
||
+ </h2>
|
||
+ <p className="muted">
|
||
+ {game.endedEarly
|
||
+ ? "답한 문제만 모아 결과를 보여 드려요."
|
||
+ : "이번 단계의 결과를 확인하고 다음 도전을 이어 가세요."}
|
||
+ </p>
|
||
+ <p>
|
||
+ 응답 {result.answered}문항 · 정답 {result.correct}문항
|
||
+ </p>
|
||
+ <div className="stats">
|
||
+ <div>
|
||
+ <strong>{result.score.toLocaleString()}</strong>
|
||
+ <span>총 점수</span>
|
||
+ </div>
|
||
+ <div>
|
||
+ <strong>{result.answered ? `${result.accuracy}%` : "—"}</strong>
|
||
+ <span>
|
||
+ 정답률 ·{" "}
|
||
+ {result.answered
|
||
+ ? `${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.total ? `${row.correct} / ${row.total}` : "미진행"}
|
||
+ </td>
|
||
+ </tr>
|
||
+ ))}
|
||
+ </tbody>
|
||
+ </table>
|
||
+ <p className="muted">
|
||
+ 정답마다 단계 × 100점 + 연속 정답 보너스 (문항별 최대{" "}
|
||
+ {Math.min(ROUND_SIZE - 1, 4) * 10}점, 라운드 합계 최대{" "}
|
||
+ {Array.from(
|
||
+ { length: ROUND_SIZE },
|
||
+ (_, i) => Math.min(i, 4) * 10,
|
||
+ ).reduce((sum, bonus) => sum + bonus, 0)}
|
||
+ 점)
|
||
+ </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.index + 1}
|
||
+ aria-label="현재 문항 진행률"
|
||
+ aria-valuetext={`${game.index + 1}/${game.questions.length}문항`}
|
||
+ />
|
||
+ <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>
|
||
+ <a href="/ambience/AMBIENCE_NOTICES.md">환경음 출처·라이선스</a>
|
||
+ </footer>
|
||
+ </>
|
||
+ );
|
||
+ }
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:19:51
|
||
17| "docs/AUDIO_PROVENANCE.md",
|
||
18| ]) {
|
||
19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5");
|
||
| ^
|
||
20| }
|
||
21| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: index.html: expected '<!doctype html>\n<!-- 문서 언어를 한국어로 명시하…' to contain '0.2.5'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.2.5
|
||
+ <!doctype html>
|
||
+ <!-- 문서 언어를 한국어로 명시하여 보조 기기의 발음과 줄바꿈 기준을 맞춘다.
|
||
+ 외부 폰트·CDN을 사용하지 않으며 모든 실행 자산은 같은 출처에서 제공한다. -->
|
||
+ <html lang="ko">
|
||
+ <head>
|
||
+ <meta charset="UTF-8" />
|
||
+ <!-- 자체 SVG 아이콘은 Chrome의 기본 favicon.ico 404 요청을 없애고 외부 자산을 추가하지 않는다. -->
|
||
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23234436'/%3E%3Cpath d='M8 13v6m5-10v14m6-12v10m5-8v6' stroke='%23dbe5b4' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E" />
|
||
+ <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.2.3</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.5");
|
||
| ^
|
||
20| }
|
||
21| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.2.3\n\n소음 속에…' to contain '0.2.5'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.2.5
|
||
+ # 단어를 맞춰라 · Find-Word v0.2.3
|
||
+
|
||
+ 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다.
|
||
+
|
||
+ **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개 실제 현장 녹음 환경 중 하나를 지정하거나 라운드마다 섞어 세 곳을 방문합니다.
|
||
+ - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다.
|
||
+ - 시작 난이도 1~5를 고르고 정확히 **3문항**을 같은 단계에서 풉니다. 단계 기본값은 **SNR +6, +3, 0, −3, −6 dB**입니다.
|
||
+ - 목표 음성 SNR을 **−12~+12 dB, 1 dB 간격**으로 직접 조절할 수 있습니다. 재생 중 변경은 다음 재생·재청취와 남은 문제부터 적용합니다.
|
||
+ 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다.
|
||
+ - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답 보너스는 문항별 최대 20점, 라운드 합계 최대 30점입니다 (0 + 10 + 20점, 1단계 만점 330점).
|
||
+ - 정상 결과에서 다음 단계의 새 3문항을 바로 시작합니다. 5단계에서는 같은 단계 재도전과 홈 복귀를 제공합니다. 수동 SNR은 다음 라운드에도 유지되고, 홈 복귀 시 선택한 시작 단계의 기본값으로 돌아갑니다.
|
||
+ - 언제든 **문제 풀이 종료**를 누르면 즉시 오디오를 취소하고 **응답 수·정답 수**를 보여 줍니다. 무응답은 오답으로 세지 않고 정답률은 `— / 응답 없음`, 답하지 않은 단계는 `미진행`으로 표시합니다. 결과에서 **처음 화면으로** 돌아갑니다.
|
||
+ - 결과의 SNR은 실제 마지막 청취값을 기록합니다. 점수·정답률·최고 연속 정답·단계별 결과를 확인합니다.
|
||
+ - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다.
|
||
+
|
||
+ 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과
|
||
+ 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은
|
||
+ [WORDS.md](docs/WORDS.md)에 설명했습니다.
|
||
+ 청취 과제는 직접 목표 식별, 연습 암호와 진짜 암호 구별, 앞 단어를 잊고 다음
|
||
+ 단어 기억, 첫째·마지막·가운데 선택, 반복 찾기, 정정 반영, 신호 조건 적용,
|
||
+ 오른쪽 표지 선택의 10가지입니다. 문장에 실제 보기의 오답도 포함됩니다.
|
||
+
|
||
+ ## 검증과 정적 빌드
|
||
+
|
||
+ ```bash
|
||
+ npm run lint
|
||
+ npm test
|
||
+ python3 -m unittest discover -s tests/python -v
|
||
+ npm run build
|
||
+ npm audit
|
||
+ npm run preview
|
||
+ ```
|
||
+
|
||
+ `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기
|
||
+ 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개와 현장음 8개가 manifest 해시 그대로
|
||
+ 포함되었는지 확인합니다. 앱 콘텐츠의 예상 101개 목록과 실제 파일 집합을 독립 비교해
|
||
+ manifest와 폴더를 함께 줄이거나 파일을 추가한 경우도 거부합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다.
|
||
+ 런타임 MIT notice와 현장음 attribution/license 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/`를 사용하므로 소스 변경 후 먼저 빌드하세요.
|
||
+ 실제 음성 재생이 끝나기를 기다려 몇 분이 걸립니다. 로컬 4173 포트를
|
||
+ 테스트 중에만 열고 종료합니다. 스크린샷과 109개 WAV의 query URL·해시·디코드
|
||
+ 결과는 `test-results/`에 저장합니다. 현재 리뷰 인수 기록은
|
||
+ `.agent/evidence-v0.2.3/review-fixes/`에 보존합니다. 기존 v0.1/v0.1.1 및 v0.2.3 증거는 덮어쓰지 않습니다.
|
||
+
|
||
+ ## 음성과 문서
|
||
+
|
||
+ 한국어 WAV 101개는 직접 쓴 문장을 Microsoft Edge online TTS의
|
||
+ `ko-KR-HyunsuMultilingualNeural`로 v0.1.1에서 생성했으며 WAV 바이트는 그대로 보존했습니다. edge-tts **7.3.1**,
|
||
+ rate `-6%`, pitch `-4Hz`이며 FFmpeg로 **24kHz mono PCM16**으로 변환합니다.
|
||
+ 생성 시 네트워크가 필요하며 앱은 저장된 WAV만 재생합니다. 환경음은 공개 라이선스의 실제 현장 녹음 8개를 각 12초로 변환해 번들합니다. 생성 음성의 독점 권리를 주장하지 않습니다.
|
||
+
|
||
+ intro와 목표 WAV는 모두 `/audio/파일명.wav?v=0.2.3`로 요청합니다.
|
||
+ `src/audio/assets.ts`의 자산 버전 한 곳을 사용하며, 같은 파일명으로 음성을
|
||
+ 교체할 때 이 값을 갱신해 기존 7일 캐시와 구분합니다. 배경은 `/ambience/환경.wav?v=0.2.3`입니다. 제품 버전은 0.2.3입니다.
|
||
+
|
||
+ 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).
|
||
+
|
||
+ 현장음의 현재 privacy screening 구간은 mall 36–48초, market 24–36초, cafe 0–12초입니다.
|
||
+ 정확한 최종 WAV의 tiny-model VAD 검사에서 세 구간 모두 segment 0개로 `requiredEmptyPassed=true`입니다.
|
||
+ 이전 실패와 no-VAD 환각 가능 text 증거는 보존했으며 no-VAD 결과는 지정 VAD gate를 대체하지 않습니다.
|
||
+ 빈 결과도 말소리·음악의 부재를 증명하지 않습니다. 독립적인 사람 청취 인증과 법적/privacy clearance는 수행하지 않았지만,
|
||
+ 제품 소유자 Oz가 2026-10-14 제공된 8개 최종 WAV 검수 묶음을 기준으로 **현재 자산 그대로 릴리스를 진행하도록 승인**했습니다.
|
||
+ 설정·해시·한계와 보존 경로는 [오디오 provenance](docs/AUDIO_PROVENANCE.md#최종-privacy-excerpt-refinement)를 참조하세요.
|
||
+
|
||
+ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
|
||
+ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
|
||
+
|
||
+ 현장음 원본은 `/tmp/findword-ambience-sources`에서만 보관하고 배포하지 않습니다.
|
||
+ `python3 scripts/ambience_audio.py`는 승인 계획의 `expectedSourceSha256`·`sourceBytes`를 전수 확인한 뒤에만 변환합니다.
|
||
+ 직접 미디어 길이는 `directMediaDurationSeconds`, 검증 상태는 `sourceVerification`에 보존합니다.
|
||
+ 8 WAV·manifest·고지를 같은 파일시스템에서 staging/검증한 뒤 Linux `renameat2(RENAME_EXCHANGE)`로
|
||
+ 폴더 전체를 교체합니다. symlink 출력은 거부하며 commit 전 실패/중단은 이전 폴더를 보존합니다.
|
||
+ PD·CC0·CC BY 4.0·CC BY-SA 4.0의 정확한 출처·저작자·변환·해시는
|
||
+ [현장음 manifest](public/ambience/manifest.json), [배포 고지](public/ambience/AMBIENCE_NOTICES.md)에 있습니다.
|
||
+ 시장 파생본은 CC BY-SA 4.0을 유지합니다. 외부 링크는 출처 확인용이며 앱은 이를 요청하지 않습니다.
|
||
+
|
||
+ - [변경 이력](CHANGELOG.md)
|
||
+ - [v0.2.3 구현·검증 결과](.agent/RESULT-v0.2.3.md)
|
||
+ - [설계와 상태 전이](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.5");
|
||
| ^
|
||
20| }
|
||
21| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: LICENSE: expected 'Find-Word v0.2.3\nPrivate / proprieta…' to contain '0.2.5'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.2.5
|
||
+ Find-Word v0.2.3
|
||
+ 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.
|
||
+
|
||
+ Third-party field recordings retain the licenses and modification notices in
|
||
+ public/ambience/AMBIENCE_NOTICES.md (shipped as ambience/AMBIENCE_NOTICES.md). The market
|
||
+ derivative remains CC BY-SA 4.0; no additional restrictions apply to that audio.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:19:51
|
||
17| "docs/AUDIO_PROVENANCE.md",
|
||
18| ]) {
|
||
19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5");
|
||
| ^
|
||
20| }
|
||
21| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# Find-Word v0.2.3 오디오 출처와 재현\n\n## 포…' to contain '0.2.5'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.2.5
|
||
+ # Find-Word v0.2.3 오디오 출처와 재현
|
||
+
|
||
+ ## 포함 자산과 생성 설정
|
||
+
|
||
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
|
||
+ | --- | --- | --- | --- |
|
||
+ | `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개 배경 환경 | 공개 라이선스 실제 현장 녹음 | `public/ambience/manifest.json` | 12초·24kHz mono PCM16 WAV |
|
||
+ | 화면 파형·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을
|
||
+ 요구한다. 0.2.3의 사용자 최대 +12dB 혼합은 세 경로 공통 감쇠로 별도 검증한다. 앱은 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 신호·무결성·재생 흐름을 확인한다. 한국어 발음, 유사 자모의
|
||
+ 구분, 웃음의 자연스러움과 실제 사용자의 난이도는 청취 평가가 필요하다.
|
||
+ 대표 샘플과 게임 내 소음 혼합 상태를 직접 들어 인수 판단한다. 배경은 실제 현장 녹음의 짧은 구간이다. 이 게임의 수치는 임상 검증·치료·청력 판정용이 아니다.
|
||
+
|
||
+
|
||
+ ## v0.2.3 현장 녹음 파생본
|
||
+
|
||
+ 원본 선정 계약은 [.agent/AMBIENCE-SOURCES-v0.2.3.json](../.agent/AMBIENCE-SOURCES-v0.2.3.json),
|
||
+ 배포 바이트의 출처·직접 URL·저작자·라이선스·변환·SHA-256·bytes·duration·RMS·peak는
|
||
+ [manifest](../public/ambience/manifest.json), 정확한 attribution과 변경 고지는
|
||
+ [AMBIENCE_NOTICES](../public/ambience/AMBIENCE_NOTICES.md)에 있다. 이 고지는 dist에도 포함되고 UI에서 링크한다.
|
||
+ 여덟 원본 모두 서로 다른 현장 녹음이며 NC/ND 자산을 사용하지 않는다. 원본은 저장소/빌드에 포함하지 않는다.
|
||
+
|
||
+ | 환경 | 저작자 | 라이선스 | 원본 구간(초) |
|
||
+ | --- | --- | --- | --- |
|
||
+ | 길거리 | cori (Cori Samuel) | PD-author | 35–47 |
|
||
+ | 쇼핑몰 | thore (Thore) | PD-author | 36–48 |
|
||
+ | 극장 | ElevatorFan2020 | CC0 1.0 | 45–57 |
|
||
+ | 백화점 | icmusic | CC0 1.0 | 90–102 |
|
||
+ | 시장 | Marie Flutre | CC BY-SA 4.0 | 24–36 |
|
||
+ | 지하철 | PascalHD | CC BY 4.0 | 65–77 |
|
||
+ | 카페 | Marble Toast | CC0 1.0 | 0–12 |
|
||
+ | 비 오는 거리 | Extemporalist | CC0 1.0 | 180–192 |
|
||
+
|
||
+ Commons primary 페이지/API와 Freesound primary 페이지의 라이선스 근거를 evidence에 보존한다.
|
||
+ Freesound 두 원본은 지정된 preview MP3이다. 프로젝트의 비공개 라이선스를 제3자 녹음에
|
||
+ 적용하지 않으며, 시장 파생 WAV는 **CC BY-SA 4.0**으로 계속 공개·수정·재배포할 수 있다.
|
||
+ 지하철은 **CC BY 4.0**의 PascalHD credit·변경 고지·라이선스 링크를 유지한다.
|
||
+
|
||
+ 재현: 검증한 원본 8개를 `/tmp/findword-ambience-sources`에 놓고
|
||
+ `python3 scripts/ambience_audio.py`를 실행한다. 계획의 파일별 `selectedSegmentStartSeconds`에서 12초를 선택하고,
|
||
+ FFmpeg로 mono 24kHz float PCM 디코드 후 양끝 50ms 선형 fade, RMS 목표 .08,
|
||
+ 최대 증폭 4배/peak .85 중 가장 작은 선형 gain을 적용한다. 마지막은 표준 44-byte
|
||
+ WAV 헤더의 PCM16이다. 시간 변형·절차적 합성·원본 음성 대체는 없다.
|
||
+ 원본 SHA-256과 실측 FFmpeg 버전을 함께 기록하므로 같은 원본/도구로 재현 가능하다.
|
||
+
|
||
+ 런타임은 음성/배경 각각 RMS .03 후 상대 gain `10^(dB/20)`을 적용한다. −12~+12 dB
|
||
+ 범위에서도 세 경로의 최악 피크 합을 공통 감쇠하여 최대 .8로 제한한다. 양끝 fade가
|
||
+ 있는 배경을 loop하고 목표 끝에서 정리한다. 전체 버퍼 RMS 기준이며 짧은 시간창별
|
||
+ SNR이나 실제 음압을 보증하지 않는다. 원본 녹음의 우연한 사람 말소리는 환경의 일부다.
|
||
+ 자동 검사로 음악·개인 대화의 완전한 부재를 인증할 수 없으므로 사람의 최종 청취 검토가 필요하다.
|
||
+
|
||
+ 기존 neural WAV 101개의 원문·해시는 v0.1.1 그대로다. 음성 manifest의 version은
|
||
+ 배포 버전 0.2.3, `generatedForVersion`은 0.1.1이며 원래 생성 시각·도구 정보는 보존한다.
|
||
+
|
||
+
|
||
+ ### 리뷰 후 출처 고정과 원자 발행
|
||
+
|
||
+ 승인 계획의 `expectedSourceSha256`와 `sourceBytes`는 불변 입력이다. 생성기는 로컬 경로나
|
||
+ 현재 파일에서 이 승인값을 새로 기록하지 않으며, 여덟 원본의 해시·크기를 **첫 변환 전**에 모두 검사한다.
|
||
+ 한 원본이라도 다르거나 누락되면 실패한다. `sourceVerification`은 검증 상태·일자·방법·보존된 증거를 가리킨다.
|
||
+ 임시 경로를 담던 `verifiedLocalSource` 필드는 제거했다.
|
||
+
|
||
+ `directMediaDurationSeconds`는 **directUrl에서 받은 바로 그 바이트**의 ffprobe `format.duration`이다.
|
||
+ Freesound 항목은 preview MP3의 길이이며 primary 페이지 원본 길이 또는 반올림한 표시 길이가 아니다.
|
||
+ 배포 manifest의 `duration`과 `conversion.durationSeconds`는 파생 WAV의 길이(12초),
|
||
+ `selectedSegmentStartSeconds`는 파일별 불변 승인 시작점이며 생성기는 별도 hardcoded map 없이 이 값을 읽는다.
|
||
+ `conversion.startSeconds`는 같은 값이어야 한다. 시작점은 유한한 0 이상 수이고, 시작점+12초가
|
||
+ `directMediaDurationSeconds` 이하여야 한다. 누락·음수·NaN·Infinity·문자열·bool·범위 초과는 첫 변환 전에 거부한다. 원본 전체 디코드와 실제 수치는
|
||
+ [보존된 source-integrity.json](../.agent/evidence-v0.2.3/source-integrity.json)에 있다.
|
||
+
|
||
+ 생성기는 출력 폴더와 같은 부모의 `.ambience-stage-*/ready`에서 8 WAV·manifest·`AMBIENCE_NOTICES.md`를
|
||
+ 완성한다. 정확한 파일 집합, 고유 해시, 원본 계획, PCM 형식·길이·fade·RMS·peak,
|
||
+ 항목별 저작자/primary URL/direct URL/라이선스 링크/outputLicense/변경 설명을 다시 검사한다.
|
||
+ 존재하는 symlink 및 dangling symlink 출력을 모두 거부한다. Linux `renameat2(RENAME_EXCHANGE)` 한 번이 교체의
|
||
+ commit 지점이며, 최초 생성은 같은 파일시스템의 rename 한 번이다. 지원하지 않는 환경에서는 실패한다.
|
||
+ 파일별 overlay를 하지 않으므로 오래된 파일이 남지 않고 고지도 음원과 같은 디렉터리로 교체된다.
|
||
+
|
||
+ commit 전 변환·쓰기·검증·교환 실패 또는 중단은 이전 디렉터리를 보존한다. syscall 이후에는 완성된 새 디렉터리가
|
||
+ 공개된 상태다. SIGKILL 등 강제 종료는 숨은 임시 디렉터리를 남길 수 있지만 부분 배포는 만들지 않는다.
|
||
+ 전원 손실에 대한 디스크 내구성이나 여러 HTTP 요청에 걸친 snapshot 격리를 주장하지 않는다.
|
||
+ 앞선 독립 리뷰에서는 기존 109 WAV를 재생성하지 않았다. 이후 아래 privacy refinement에서
|
||
+ 전체 현장음 bundle을 원자 재생성했으며 mall/market/cafe 3개만 바이트가 바뀌었다.
|
||
+ **사람의 현장음 청취 인수는 미실행**이다.
|
||
+
|
||
+
|
||
+ ### 최종 privacy excerpt refinement
|
||
+
|
||
+ 현재 선택은 **mall 36–48초, market 24–36초, cafe 0–12초**다. Neo가 동일한
|
||
+ `scripts/ambience_audio.convert` 경로와 아래 ASR 설정으로 해시 고정 원본을 재검사해 제시한
|
||
+ market/cafe 후보를 사용했다. 제공받은 재검사 설명은
|
||
+ [selection-context.json](../.agent/evidence-v0.2.3/privacy-screening/retry-market24-cafe0/selection-context.json)에
|
||
+ 별도로 기록했으며, 원본 후보 결과를 최종 WAV 검사 결과로 대신하지 않았다.
|
||
+
|
||
+ 승인 원본 8개의 SHA-256·bytes·ffprobe 길이를 먼저 대조하고 하드닝된 생성기로
|
||
+ 8 WAV·manifest·notice 전체를 staging/검증/원자 교환했다.
|
||
+ [생성 실측 기록](../.agent/evidence-v0.2.3/privacy-screening/retry-market24-cafe0/regeneration.json)에
|
||
+ 현재 해시·RMS·peak·변환이 있다. 이번 재시도는 market/cafe 두 WAV만 변경했다.
|
||
+ **101개 voice, 다섯 unaffected ambience, 기존 mall 36초 WAV는 모두 바이트 동일**하다.
|
||
+
|
||
+ 일부 Ogg 구간은 FFmpeg timestamp/seek 때문에 12초보다 몇 프레임 짧게 출력된다.
|
||
+ 짧은 결과일 때만 처음부터 디코드한 24kHz PCM의 `round(start × 24000)`부터 정확한 288,000개
|
||
+ sample을 선택하고 출력 PTS를 sample 수로 재설정한다. silent padding이나 time stretching은 없다.
|
||
+ 정말 원본이 짧으면 여전히 실패하며 `conversion.seekMethod`가 실제 경로를 기록한다.
|
||
+
|
||
+ [final-asr.json](../.agent/evidence-v0.2.3/privacy-screening/final-asr.json)은 정확한 현재 8 WAV를
|
||
+ 로컬 **faster-whisper 1.2.1, tiny multilingual, CPU int8, beam_size=5, best_of=5,
|
||
+ 기본 Silero VAD 활성화, condition_on_previous_text=true**로 검사한 원문이다.
|
||
+ 모델 revision `d90ca5fe260221311c53c58e660288d3deb8d356`, 모델/VAD 파일 해시,
|
||
+ 라이브러리 버전·전체 설정·각 입력 해시·반환 segment/info를 저장했다.
|
||
+ 이전 실패 실행과 모든 decoder/VAD 인자가 같은지 스크립트가 대조한다.
|
||
+ **mall/market/cafe 각각 segment 0개, duration_after_vad=0.0, requiredEmptyPassed=true**다.
|
||
+ 다른 다섯 WAV도 segment 0개다. threshold 조절이나 반환 segment 삭제는 하지 않았다.
|
||
+ 테스트는 지정 시작점·true gate·VAD 설정·실제 WAV/manifest 해시와 아래 false 인수값을 함께 강제한다.
|
||
+
|
||
+ **humanListeningAcceptance=false, legalPrivacyClearance=false**를 유지한다.
|
||
+ Tiny-model ASR은 screening signal일 뿐이며 빈 결과는 말소리·음악·개인 대화가 없음을 증명하지 않는다.
|
||
+ 출력 text/language도 환각일 수 있고 충실한 전사나 화자 식별로 주장하지 않는다.
|
||
+ 독립적인 사람 청취 인증은 수행하지 않았다. 다만 제품 소유자 Oz는 2026-10-14 제공된
|
||
+ 최종 WAV 8개 청취 검수 묶음과 명시된 자동 검사 한계를 기준으로 현재 자산 그대로 릴리스를
|
||
+ 진행하도록 승인했다. 이 승인은 제3자의 법적/privacy clearance를 대신하지 않는다.
|
||
+
|
||
+ 이전 market 60–72초/cafe 180–192초 실행은 market 3개/cafe 1개 segment를 반환하여
|
||
+ **requiredEmptyPassed=false**였으며 release acceptance 실패였다.
|
||
+ 그 실행의 모든 screening 파일은 읽기 전용
|
||
+ [prior-attempt-market60-cafe180](../.agent/evidence-v0.2.3/privacy-screening/prior-attempt-market60-cafe180/)
|
||
+ 아래 바이트 그대로 보존했다. [당시 final-asr.json](../.agent/evidence-v0.2.3/privacy-screening/prior-attempt-market60-cafe180/final-asr.json)을
|
||
+ 성공으로 덮어쓰지 않았고 회귀 테스트가 이전 실패 상태와 모든 보존 파일 해시를 검사한다.
|
||
+
|
||
+ [기존 no-VAD 증거](../.agent/evidence-v0.2.3/privacy-screening/no-vad-asr.json)도 바이트 그대로 남겼다.
|
||
+ 이 결과는 mall 36초/market 60초/cafe 180초에서 환각일 수 있는 candidate text를 냈으며,
|
||
+ 새 market/cafe WAV의 검사 결과가 아니다. **no-VAD 결과는 지정된 VAD gate를 대체하거나 뒤집지 않는다.**
|
||
+ 두 방법 모두 말소리/음악의 부재를 증명하지 않으며 사람의 청취 인수나 법적/privacy clearance를 제공하지 않는다.
|
||
+ Neo의 [이전 후보 scan](../.agent/evidence-v0.2.3/privacy-screening/candidate-scan-original.json),
|
||
+ [이전 파생본 ASR](../.agent/evidence-v0.2.3/privacy-screening/prior-derivatives-asr-original.json),
|
||
+ [당시 provenance](../.agent/evidence-v0.2.3/privacy-screening/earlier-asr-provenance.json)도 변경하지 않았다.
|
||
+ raw JSON에 없던 설정이나 입력 연결을 사후에 있었던 것으로 주장하지 않는다.
|
||
+
|
||
+ 새 RED → GREEN, 전체 검증과 보존 기록은
|
||
+ [retry-market24-cafe0](../.agent/evidence-v0.2.3/privacy-screening/retry-market24-cafe0/),
|
||
+ 현재 검증 요약은 [RESULT](../.agent/RESULT-v0.2.3.md)의 마지막 절에 있다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:19:51
|
||
17| "docs/AUDIO_PROVENANCE.md",
|
||
18| ]) {
|
||
19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5");
|
||
| ^
|
||
20| }
|
||
21| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다
|
||
AssertionError: docs/ARCHITECTURE.md: expected '# Find-Word v0.2.3 설계\n\n## 데이터와 출제\n…' to contain '0.2.5'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.2.5
|
||
+ # Find-Word v0.2.3 설계
|
||
+
|
||
+ ## 데이터와 출제
|
||
+
|
||
+ `src/content.ts`는 패턴, 질문, 단어, 오답 규칙, 현장음 URL, 상대 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로
|
||
+ 유형·단어·정답 위치를 섞는다. 한 라운드는 3문항이며 유형과 정답 단어가 중복되지 않는다. 시작 단계는 1~5로
|
||
+ 정규화하며 세 문항에 같은 level/db를 적용한다. 혼합 모드는 선택/입력/선택이다.
|
||
+ 환경 순환은 seed로 여덟 곳을 섞어 세 곳을 선택한다. 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 (세 번째 문제)
|
||
+ ready --> results: EXIT
|
||
+ loading --> results: EXIT
|
||
+ playing --> results: EXIT
|
||
+ answer --> results: EXIT
|
||
+ feedback --> results: EXIT
|
||
+ ```
|
||
+
|
||
+ 정상 결과는 다음 단계(최대 5)의 새 라운드로 연결되며 중도 종료는 응답 수·정답 수를
|
||
+ 표시한 뒤 홈으로 연결된다. 무응답 정답률은 `— / 응답 없음`, 응답하지 않은 단계는 `미진행`으로 표시한다.
|
||
+ 중도 종료 제목과 현재 단계 완료 제목을 구분한다. 진행 막대는 답변 수가 아닌 현재 문항의 1/3, 2/3, 3/3이다.
|
||
+ 결과 단계는 모든 지연 이벤트를 무시한다.
|
||
+ SNR은 −12~+12 dB 정수로 제한하고 NaN/Infinity는 단계 기본값으로 복구한다.
|
||
+ 수동 SNR은 재생 도중 현재 gain을 바꾸지 않고 현재 재청취/남은 문항/다음 라운드에
|
||
+ 적용한다. `heardDb`는 실제 재생 시작값을 보존해 결과를 정확히 기록한다. 홈은
|
||
+ 수동 SNR·답·입력·IME·토큰을 초기화하고 시작 단계·모드·환경·전체 음량은 유지한다.
|
||
+
|
||
+ 허용되지 않은 이벤트는 동일 상태를 반환한다. 재생 전/중에는 채점할 수 없고,
|
||
+ 피드백 이후의 중복 답변·다음 클릭도 한 번만 반영된다. UI에서도 버튼 비활성화와
|
||
+ 동기 ref 잠금을 함께 사용한다. 세션 토큰으로 처음 화면 복귀·unmount 뒤 완료된
|
||
+ Promise를 무시한다. 질문과 보기는 재생 완료 후 DOM에 생성되며 정답 문장은
|
||
+ 채점 후에만 표시된다.
|
||
+
|
||
+ 주관식은 NFC 한글 정규화 후 공백과 유니코드 문장부호만 제거한다. 비어 있는
|
||
+ 답변과 부분 일치는 정답으로 인정하지 않는다. React 텍스트 노드로만 출력하여
|
||
+ 입력을 HTML로 해석하지 않는다.
|
||
+
|
||
+ 점수는 정답 시 `100 × 단계 + 10 × min(연속 정답 - 1, 4)`이다. 오답은 0점,
|
||
+ 현재 연속 정답을 초기화한다. 기존 공식의 cap은 40점이며 세 문항 라운드에서는 문항별 최대 20점, 라운드 합계 최대 30점(0 + 10 + 20)이다. 1단계 세 정답은 330점이다. 최고 연속 정답과 난이도별 정답 수는 유지한다.
|
||
+
|
||
+ ## 오디오
|
||
+
|
||
+ 101개 저장 WAV는 edge-tts 7.3.1의 `ko-KR-HyunsuMultilingualNeural`로
|
||
+ 빌드 전에 생성한다. 24kHz mono PCM16이며 원문·도구·해시는 manifest에 남긴다.
|
||
+ 생성기는 임시 디렉터리에서 전체 검증 후 원자 교체한다. 생성 절차는
|
||
+ [AUDIO_PROVENANCE](AUDIO_PROVENANCE.md)에 있으며 앱은 provider에 연결하지 않는다.
|
||
+
|
||
+ `src/audio/dsp.ts`는 순수 수치 함수이며 장치가 없어도 테스트할 수 있다.
|
||
+ 8개 현장 녹음 WAV를 디코드해 재생한다. 각 12초, 24kHz mono PCM16이며 양끝
|
||
+ 50ms fade로 루프 접합 클릭을 줄인다. 절차적 잡음 합성 코드는 제거했다.
|
||
+
|
||
+ `src/audio/engine.ts`는 클릭 이벤트에서 AudioContext 생성·resume을 호출한 뒤
|
||
+ 같은 출처의 WAV를 로드한다. mono PCM을 decode하고 음성과 배경의 전체 버퍼
|
||
+ RMS를 0.03으로 정규화한다. `gain = 10^(dB/20)`으로 목표/배경 비율을 지정한다. 정규화한 세 신호의 최대
|
||
+ 피크 합을 계산해 모든 경로를 공통 감쇠하므로 합계 peak ≤ 0.8이고 SNR이 유지된다.
|
||
+ 시간별·주파수별 청감이나 물리적 dB SPL을 보장하는 수치는 아니다.
|
||
+
|
||
+ `src/audio/assets.ts`는 음성 자산 버전과 URL 생성을 한 곳에서 관리한다.
|
||
+ intro·목표 100개·현장음 8개 모두 `?v=0.2.3`을 붙여 동일 파일명에 남은 이전 캐시를
|
||
+ 피한다. 파일을 다시 생성하면 제품 버전 유지 여부와 관계없이 자산 버전을
|
||
+ 갱신한다. 버전 확인을 위한 외부 요청이나 런타임 manifest 요청은 없다.
|
||
+
|
||
+ ```text
|
||
+ 배경 BufferSource → 배경 Gain (공통 감쇠, 시작/끝 fade) ─┐
|
||
+ 목표 BufferSource → 음성 Gain (공통 감쇠 × 10^(dB/20)) ─────┼→ master Gain → destination
|
||
+ 안내 BufferSource → 안내 Gain (공통 감쇠 × 1.8 → 0.12) ─────┘
|
||
+ ```
|
||
+
|
||
+ 배경 소스는 `loop=true`이며 목표 ended에서 즉시 stop/disconnect한다.
|
||
+ 각 라운드의 첫 문제에서는 웃음과 안내 음성을 낮추어 배경에 묻히게 한 후 목표 문장을
|
||
+ 재생한다. 이후에는 목표만 재생한다. 안내 도중 실패했으면 재시도 시 안내도
|
||
+ 다시 재생한다. 기본 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로 두 크기의 3문항
|
||
+ 완주·다음 단계·SNR·중도 종료·재청취·오류 복구·axe 검사를 수행한다. 테스트 자동화는 한국어 발음의
|
||
+ 자연스러움, 실제 사용자의 청각 난이도, 모바일 Safari의 장치별 정책까지
|
||
+ 증명하지 않으므로 인수 시 직접 청취를 추가한다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:55:51
|
||
53| "src/audio/assets.ts",
|
||
54| ])
|
||
55| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5");
|
||
| ^
|
||
56| const history = readFileSync("CHANGELOG.md", "utf8");
|
||
57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"])
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[13/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다
|
||
AssertionError: docs/DEPLOYMENT.md: expected '# Find-Word v0.2.3 정적 배포 인계\n\n이 문서는 …' to contain '0.2.5'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.2.5
|
||
+ # Find-Word v0.2.3 정적 배포 인계
|
||
+
|
||
+ 이 문서는 **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.2.3 manifest가 반드시 포함되어야 한다.
|
||
+ `dist/ambience/`의 고유 현장 WAV 8개·manifest와 `dist/ambience/AMBIENCE_NOTICES.md`도 필수다.
|
||
+ 런타임은 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 HTML로 대체하지 않는다.
|
||
+ location /ambience/ {
|
||
+ 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.2.3`와 목표 WAV의 같은 버전 query가 정상 응답하고
|
||
+ 존재하지 않는 오디오는 query가 있어도 404인지 확인한다.
|
||
+ 4. 모바일 Safari/Chrome, 데스크톱에서 음량·중단·다시 듣기·3문항 완주·다음 난이도·시작 단계·SNR 변경·중도 종료를 직접 확인한다.
|
||
+ 5. 원자적으로 릴리스 디렉터리를 전환하고 이전 디렉터리를 보관해 되돌릴 수 있게 한다.
|
||
+
|
||
+ 오프라인 캐시를 제공하는 PWA는 아니다. 정적 자산의 최초 로드에는 해당 서버
|
||
+ 연결이 필요하다. HTML은 재검증하고, 오디오는 URL의 자산 버전으로 구분한다.
|
||
+ 같은 파일명으로 WAV를 교체할 때 `src/audio/assets.ts`의 `AUDIO_ASSET_VERSION`도
|
||
+ 갱신한다. 제품 버전을 유지하는 음성 수정에서도 새 자산 버전이 필요하다.
|
||
+ 이 query는 로컬 정적 파일 요청에만 사용하며 런타임 외부 요청을 추가하지 않는다.
|
||
+
|
||
+
|
||
+ ## 0.2.3 독립 인수와 릴리스 (Neo 수행, 이 작업에서는 미실행)
|
||
+
|
||
+ 1. `.agent/RESULT-v0.2.3.md`와 RED/GREEN 원문, 원본/배포 해시, 라이선스 고지를 검토한다.
|
||
+ 2. sandboxed system Chrome desktop/mobile suite와 `npm audit`를 다시 실행한다.
|
||
+ 3. 검토 완료 후 feature branch push, main fast-forward, annotated `v0.2.3` tag,
|
||
+ fresh-clone readback으로 commit과 자산 해시를 확인한다.
|
||
+ 4. commit-addressed release 디렉터리로 dist를 복사하고 current 심볼릭 링크를 원자 전환한다.
|
||
+ 이전 release는 rollback 대상으로 보존한다.
|
||
+ 5. 공개 HTTPS에서 title/UI 0.2.3, WAV MIME·SHA-256·cache-busting query, 누락 자산 404,
|
||
+ 시작 단계·3문항 완료→다음 단계·SNR·중도 종료를 실제 Chrome으로 재검증한다.
|
||
+
|
||
+ 이 문서의 Nginx 설정은 인계 예시이며 실행 증거가 아니다. 이번 작업은 localhost의
|
||
+ production preview만 사용하며 Git commit/push/merge/tag, 외부 배포, secrets 사용은 하지 않는다.
|
||
+
|
||
+
|
||
+ v0.2.3 리뷰 검증은 `.agent/evidence-v0.2.3/review-fixes/`에 별도로 보존한다.
|
||
+ `check-build`는 앱 콘텐츠로 구성한 **정확히 101개**의 음성 파일명과 dist 파일 집합을 독립 대조하므로,
|
||
+ manifest와 디렉터리를 함께 축약하거나 추가 WAV를 넣어도 실패한다. 현장음은 승인 계획의 출처 수치와
|
||
+ 각 notice 항목의 저작자·primary URL·direct URL·라이선스 링크·outputLicense·변경 고지까지 검사한다.
|
||
+ 현장음 notice의 배포 URL은 `/ambience/AMBIENCE_NOTICES.md`다. 고지는 음원/manifest와 함께
|
||
+ 원자 교체하는 같은 디렉터리 안에 있으며, 기존 루트의 notice 사본은 제거했다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:55:51
|
||
53| "src/audio/assets.ts",
|
||
54| ])
|
||
55| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5");
|
||
| ^
|
||
56| const history = readFileSync("CHANGELOG.md", "utf8");
|
||
57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"])
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다
|
||
AssertionError: docs/WORDS.md: expected '# Find-Word v0.2.3 단어와 오답 변형\n\n임의의 한…' to contain '0.2.5'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.2.5
|
||
+ # Find-Word v0.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의 예상 혼동 순서를
|
||
+ 기록한다. 각 묶음의 세 오답은 서로 다른 규칙과 순위를 가진다. 이 순서는
|
||
+ 출제 메타데이터이며 청감·발음·청력에 대한 실측 난이도나 임상 척도가 아니다.
|
||
+ 새 단어를 넣을 때는 표제어·의미·변형 규칙을 검토하고 이 목록을 갱신한다.
|
||
+
|
||
+ 0.2.3은 기존 10가지 mechanic·100문장을 보존하며 한 라운드에서 서로 다른 세 문항을 고른다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:55:51
|
||
53| "src/audio/assets.ts",
|
||
54| ])
|
||
55| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5");
|
||
| ^
|
||
56| const history = readFileSync("CHANGELOG.md", "utf8");
|
||
57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"])
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다
|
||
AssertionError: public/audio/manifest.json: expected '{\n "version": "0.2.3",\n "provider…' to contain '0.2.5'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.2.5
|
||
+ {
|
||
+ "version": "0.2.3",
|
||
+ "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
|
||
+ }
|
||
+ ],
|
||
+ "generatedForVersion": "0.1.1",
|
||
+ "releaseNote": "0.2.3 retains all 101 original v0.1.1 neural WAV bytes; only release metadata changed."
|
||
+ }
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:55:51
|
||
53| "src/audio/assets.ts",
|
||
54| ])
|
||
55| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5");
|
||
| ^
|
||
56| const history = readFileSync("CHANGELOG.md", "utf8");
|
||
57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"])
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다
|
||
AssertionError: src/audio/assets.ts: expected '// WAV를 같은 파일명으로 교체하면 장기 캐시가 남는다. 배포 …' to contain '0.2.5'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.2.5
|
||
+ // WAV를 같은 파일명으로 교체하면 장기 캐시가 남는다. 배포 음원이 바뀔 때
|
||
+ // 이 자산 버전도 갱신한다. 제품 버전과 별개로 관리하며 intro·목표에 함께 쓴다.
|
||
+ export const AUDIO_ASSET_VERSION = "0.2.3";
|
||
+
|
||
+ // 안내와 목표 음성의 같은 출처 경로를 만들고 공통 버전으로 캐시를 구분한다.
|
||
+ 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:55:51
|
||
53| "src/audio/assets.ts",
|
||
54| ])
|
||
55| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5");
|
||
| ^
|
||
56| const history = readFileSync("CHANGELOG.md", "utf8");
|
||
57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"])
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[17/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다
|
||
AssertionError: expected '# Find-Word 변경 이력\n\n## 0.2.3 — 2026-…' to contain '0.2.5'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.2.5
|
||
+ # Find-Word 변경 이력
|
||
+
|
||
+ ## 0.2.3 — 2026-10-14 (로컬 구현, 배포 미실행)
|
||
+
|
||
+ - 라운드를 정확히 3문항으로 변경하고 한 단계의 난이도를 유지한다. 시작 단계 1~5 선택,
|
||
+ 정상 완료 후 다음 단계 새 라운드, 5단계 재도전을 제공한다. 혼합 모드는 선택/입력을 모두 포함한다.
|
||
+ - SNR 직접 제어: −12~+12 dB, 1 dB 간격, 범위/NaN 정규화. 재생 중 변경은 다음 청취부터 적용,
|
||
+ 이후 문항/라운드에 유지하고 홈 복귀 시 단계 기본값으로 초기화한다. 양/0/음수의 의미를 설명한다.
|
||
+ - 중도 종료는 fetch·Web Audio·타이머를 즉시 취소하고 응답/정답 수를 보여 준 뒤 홈으로 연결한다.
|
||
+ 무응답을 오답으로 세지 않고 지연 콜백·중복 답변을 차단한다.
|
||
+ - 절차적 배경 합성을 제거하고 실제 현장 녹음 8개를 same-origin WAV로 번들한다.
|
||
+ 각 12초·24kHz mono PCM16·50ms fade·안전 선형 정규화·고유 SHA-256, 원본은 번들하지 않는다.
|
||
+ - PD/CC0/CC BY 4.0/CC BY-SA 4.0 원본을 추적하는 machine-readable manifest와 브라우저 배포 고지.
|
||
+ 시장 파생본의 ShareAlike와 지하철 credit/변경 고지 유지. 원본/다운로드 URL·저작자·변환·해시·실측값 기록.
|
||
+ - 녹음을 fetch/decode/loop하며 RMS .03 정규화 후 상대 gain을 적용한다. 공통 peak 감쇠로 SNR을
|
||
+ 유지하면서 clipping headroom을 확보한다. 404/decode/timeout/abort는 재시도 가능한 한국어 안내를 제공한다.
|
||
+ - 회귀 수정: 세 문항 표본에 맞게 전체 콘텐츠 도달성 검사 확대; 콘텐츠의 Node 직접 import 경로 보존;
|
||
+ 재생 중 SNR 변경이 실제 청취 이력을 덮어쓰지 않도록 결과에 청취 시작값 기록.
|
||
+ - 추가 회귀 수정: 3문항에서도 순환 환경이 여덟 장소에 도달하도록 섞고, 결과의 잘못된 플레이 방법 앵커를 제거한다.
|
||
+ 세 문항의 문항별 보너스 상한(20점)과 라운드 합계 상한(30점)을 안내하고 새 단계/SNR 컨트롤의 44px 터치 영역을 보장한다.
|
||
+ 개발/preview에서 HTML Accept가 있는 누락 WAV도 SPA fallback 대신 404로 응답한다.
|
||
+ 모바일 시작 단계 선택값의 잘림을 막도록 중복 SNR을 별도 제어에 모았다.
|
||
+ 내장 SVG favicon으로 Chrome의 기본 favicon.ico 404 콘솔 오류를 제거한다.
|
||
+ - 기존 10 mechanic·100문장·101 neural WAV, 채점·점수·IME·재청취·음량·접근성·개인정보 미수집을 보존한다.
|
||
+ - package/lock/HTML/UI/manifest/문서를 0.2.3으로 정렬하고 음원 query 버전을 갱신한다.
|
||
+ `Designed by OZ at Heat-T Medical Inc.`와 v0.1/v0.1.1 역사 evidence는 보존한다.
|
||
+ - strict vertical-slice TDD RED/GREEN, Python 변환 계약, 빌드 자산/notice 검증,
|
||
+ sandboxed system Chrome desktop/mobile의 실제 WAV decode/playback·스크린샷·axe·네트워크 검사를 수행한다.
|
||
+ 정확한 실행 결과와 추가 회귀는 `.agent/RESULT-v0.2.3.md`에 기록한다.
|
||
+ - Nginx 현장음 404와 원자 배포/rollback 인계 문서를 갱신한다. commit/push/merge/tag/배포는 Neo 인수 이후 별도 작업이다.
|
||
+
|
||
+ ### v0.2.3 독립 리뷰 수정
|
||
+
|
||
+ - 진행 막대를 현재 문항 1/3·2/3·3/3으로 정정하고 접근성 값 및 Chrome 실제 채움 픽셀을 검사한다.
|
||
+ - 보너스 문구를 문항별 최대 20점 / 라운드 합계 0+10+20=30점으로 정정한다. 1단계 만점 330점과 함께 검증한다.
|
||
+ - 무응답 정답률은 `— / 응답 없음`, 응답 없는 단계는 `미진행`; 중도 종료와 현재 단계 완료 제목을 구분한다.
|
||
+ - 320px·360px에서 모드 설명과 결과의 한국어 어절 분리·넘침을 방지한다. README 현재 증거 경로를 고치고 일본어 테스트 제목을 한국어로 바꾼다.
|
||
+ - 음성 manifest/폴더를 함께 축약해도 실패하도록 콘텐츠 기반 정확한 101개 목록, 개수 및 dist 추가/누락 파일을 검사한다.
|
||
+ - 현장음 승인 계획/manifest의 임시 경로를 불변 원본 해시·bytes·검증 상태 및 정확한 직접 미디어 길이로 교체한다.
|
||
+ - 현장음 생성 전 여덟 원본의 승인 해시·bytes를 전수 확인한다. 잘못된 원본은 첫 변환 전에 실패한다.
|
||
+ - 같은 파일시스템 staging에서 8 WAV·manifest·notice 전부 검증 후 Linux 원자 디렉터리 교환. stale 파일 제거,
|
||
+ symlink 출력 거부, 변환·쓰기·검증·교환 실패/중단 시 이전 디렉터리 보존을 테스트한다.
|
||
+ - 고지를 `/ambience/AMBIENCE_NOTICES.md`로 옮겨 함께 발행하고 각 항목의 creator/primary URL/direct URL/
|
||
+ license URL/outputLicense/변경 설명을 빌드에서도 강제한다. UI 링크와 권리·배포·재현 문서를 갱신한다.
|
||
+ - 101 voice + 8 ambience WAV 바이트와 모든 이전 evidence를 보존한다. 이번 리뷰의 RED/GREEN과 전체 실행 결과는
|
||
+ `.agent/evidence-v0.2.3/review-fixes/` 및 `.agent/RESULT-v0.2.3.md`를 참조한다. 사람의 청취 인수는 미실행이다.
|
||
+
|
||
+ ### v0.2.3 최종 privacy excerpt refinement
|
||
+
|
||
+ - `selectedSegmentStartSeconds`를 생성기의 불변 입력으로 유지하며 현재 선택을
|
||
+ **mall 36–48초, market 24–36초, cafe 0–12초**로 고정한다.
|
||
+ 원본 해시·bytes·길이를 확인한 뒤 하드닝된 생성기로 8 WAV·manifest·notice를 원자 재생성했다.
|
||
+ 101 voice와 다섯 unaffected ambience 및 mall WAV는 바이트 동일하며 market/cafe만 변경했다.
|
||
+ - release test를 먼저 실패시켜 정확한 세 시작점, `requiredEmptyPassed=true`, VAD 설정,
|
||
+ `humanListeningAcceptance=false`와 `legalPrivacyClearance=false`를 강제한 뒤 새 WAV를 검사했다.
|
||
+ - 정확한 최종 WAV의 faster-whisper 1.2.1 tiny multilingual CPU int8, beam_size=5, best_of=5,
|
||
+ 기본 VAD, condition_on_previous_text=true 결과는 세 환경 모두 segment 0개,
|
||
+ duration_after_vad=0.0으로 지정된 VAD gate를 통과했다. 설정 조절이나 segment 삭제는 없다.
|
||
+ - 이전 market 60초/cafe 180초 실행은 segment 3개/1개로 실패했다. 당시 `requiredEmptyPassed=false`
|
||
+ 보고서와 모든 screening 파일을 `privacy-screening/prior-attempt-market60-cafe180/`에
|
||
+ 읽기 전용·해시 고정 사본으로 보존한다. 이전 실패를 성공으로 다시 기록하지 않는다.
|
||
+ - no-VAD 환각 가능 text 증거도 그대로 보존한다. 이는 이전 파생본의 결과이고 지정 VAD gate를
|
||
+ 대체하거나 뒤집지 않으며 말소리/음악의 부재도 증명하지 않는다. 독립적인 사람 청취 인증과
|
||
+ 법적/privacy clearance는 미실행이지만, 제품 소유자 Oz가 2026-10-14 제공된 최종 WAV 8개
|
||
+ 검수 묶음을 기준으로 **현재 자산 그대로 릴리스 진행을 승인**했다.
|
||
+ - 기존 Ogg sample-trim fallback·원자 교환·출처/고지 검증을 유지한다.
|
||
+ 새 RED/GREEN·전체 검증은 `.agent/evidence-v0.2.3/privacy-screening/retry-market24-cafe0/`,
|
||
+ 현재 결과는 `.agent/RESULT-v0.2.3.md` 마지막 절에 있다. Git commit/push/merge/tag/deploy는 하지 않았다.
|
||
+
|
||
+ ## 0.1.1 — 2026-10-14
|
||
+
|
||
+ - Microsoft Edge online TTS `ko-KR-HyunsuMultilingualNeural`, edge-tts 7.3.1,
|
||
+ rate −6%, pitch −4Hz로 안내 1개·목표 100개를 새로 생성하고 24kHz mono PCM16으로 변환했다.
|
||
+ - pinned venv, bounded retry/timeout, 원자 자산 교환, provider/도구/출력물의 권리 구분과 provenance를 추가했다.
|
||
+ - query cache-busting, 시스템 Chrome sandbox 기본 활성화, 화면·음량·한국어 제목과 SNR 설명의 검증을 보강했다.
|
||
+ - 원본 기록: [.agent/RESULT-v0.1.1.md](.agent/RESULT-v0.1.1.md), `.agent/evidence-v0.1.1/`.
|
||
+
|
||
+ ## 0.1.0 — 2026-10-14 (당시 UI 표기 v0.1)
|
||
+
|
||
+ - 반응형 시작/게임/결과, 열 문항·다섯 단계, 객관식/주관식/혼합, 10 mechanic·100문장.
|
||
+ - 점수·연속 정답·재청취·IME 보호·접근성·동시 재생/취소/실패 복구, 여덟 합성 배경.
|
||
+ - 정적 SPA, 개인정보 저장·외부 런타임 요청 없음, 정확한 credit 및 private 프로젝트 권리 고지.
|
||
+ - 원본 기록: [.agent/RESULT.md](.agent/RESULT.md), `.agent/evidence/`.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:58:21
|
||
56| const history = readFileSync("CHANGELOG.md", "utf8");
|
||
57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"])
|
||
58| expect(history).toContain(version);
|
||
| ^
|
||
59| for (const change of [
|
||
60| "SNR",
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[18/20]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > README는 현재 리뷰 증거 경로와 정확한 라운드 보너스를 안내한다
|
||
AssertionError: expected '# 단어를 맞춰라 · Find-Word v0.2.3\n\n소음 속에…' to contain '.agent/evidence-v0.2.5/'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- .agent/evidence-v0.2.5/
|
||
+ # 단어를 맞춰라 · Find-Word v0.2.3
|
||
+
|
||
+ 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다.
|
||
+
|
||
+ **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개 실제 현장 녹음 환경 중 하나를 지정하거나 라운드마다 섞어 세 곳을 방문합니다.
|
||
+ - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다.
|
||
+ - 시작 난이도 1~5를 고르고 정확히 **3문항**을 같은 단계에서 풉니다. 단계 기본값은 **SNR +6, +3, 0, −3, −6 dB**입니다.
|
||
+ - 목표 음성 SNR을 **−12~+12 dB, 1 dB 간격**으로 직접 조절할 수 있습니다. 재생 중 변경은 다음 재생·재청취와 남은 문제부터 적용합니다.
|
||
+ 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다.
|
||
+ - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답 보너스는 문항별 최대 20점, 라운드 합계 최대 30점입니다 (0 + 10 + 20점, 1단계 만점 330점).
|
||
+ - 정상 결과에서 다음 단계의 새 3문항을 바로 시작합니다. 5단계에서는 같은 단계 재도전과 홈 복귀를 제공합니다. 수동 SNR은 다음 라운드에도 유지되고, 홈 복귀 시 선택한 시작 단계의 기본값으로 돌아갑니다.
|
||
+ - 언제든 **문제 풀이 종료**를 누르면 즉시 오디오를 취소하고 **응답 수·정답 수**를 보여 줍니다. 무응답은 오답으로 세지 않고 정답률은 `— / 응답 없음`, 답하지 않은 단계는 `미진행`으로 표시합니다. 결과에서 **처음 화면으로** 돌아갑니다.
|
||
+ - 결과의 SNR은 실제 마지막 청취값을 기록합니다. 점수·정답률·최고 연속 정답·단계별 결과를 확인합니다.
|
||
+ - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다.
|
||
+
|
||
+ 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과
|
||
+ 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은
|
||
+ [WORDS.md](docs/WORDS.md)에 설명했습니다.
|
||
+ 청취 과제는 직접 목표 식별, 연습 암호와 진짜 암호 구별, 앞 단어를 잊고 다음
|
||
+ 단어 기억, 첫째·마지막·가운데 선택, 반복 찾기, 정정 반영, 신호 조건 적용,
|
||
+ 오른쪽 표지 선택의 10가지입니다. 문장에 실제 보기의 오답도 포함됩니다.
|
||
+
|
||
+ ## 검증과 정적 빌드
|
||
+
|
||
+ ```bash
|
||
+ npm run lint
|
||
+ npm test
|
||
+ python3 -m unittest discover -s tests/python -v
|
||
+ npm run build
|
||
+ npm audit
|
||
+ npm run preview
|
||
+ ```
|
||
+
|
||
+ `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기
|
||
+ 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개와 현장음 8개가 manifest 해시 그대로
|
||
+ 포함되었는지 확인합니다. 앱 콘텐츠의 예상 101개 목록과 실제 파일 집합을 독립 비교해
|
||
+ manifest와 폴더를 함께 줄이거나 파일을 추가한 경우도 거부합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다.
|
||
+ 런타임 MIT notice와 현장음 attribution/license 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/`를 사용하므로 소스 변경 후 먼저 빌드하세요.
|
||
+ 실제 음성 재생이 끝나기를 기다려 몇 분이 걸립니다. 로컬 4173 포트를
|
||
+ 테스트 중에만 열고 종료합니다. 스크린샷과 109개 WAV의 query URL·해시·디코드
|
||
+ 결과는 `test-results/`에 저장합니다. 현재 리뷰 인수 기록은
|
||
+ `.agent/evidence-v0.2.3/review-fixes/`에 보존합니다. 기존 v0.1/v0.1.1 및 v0.2.3 증거는 덮어쓰지 않습니다.
|
||
+
|
||
+ ## 음성과 문서
|
||
+
|
||
+ 한국어 WAV 101개는 직접 쓴 문장을 Microsoft Edge online TTS의
|
||
+ `ko-KR-HyunsuMultilingualNeural`로 v0.1.1에서 생성했으며 WAV 바이트는 그대로 보존했습니다. edge-tts **7.3.1**,
|
||
+ rate `-6%`, pitch `-4Hz`이며 FFmpeg로 **24kHz mono PCM16**으로 변환합니다.
|
||
+ 생성 시 네트워크가 필요하며 앱은 저장된 WAV만 재생합니다. 환경음은 공개 라이선스의 실제 현장 녹음 8개를 각 12초로 변환해 번들합니다. 생성 음성의 독점 권리를 주장하지 않습니다.
|
||
+
|
||
+ intro와 목표 WAV는 모두 `/audio/파일명.wav?v=0.2.3`로 요청합니다.
|
||
+ `src/audio/assets.ts`의 자산 버전 한 곳을 사용하며, 같은 파일명으로 음성을
|
||
+ 교체할 때 이 값을 갱신해 기존 7일 캐시와 구분합니다. 배경은 `/ambience/환경.wav?v=0.2.3`입니다. 제품 버전은 0.2.3입니다.
|
||
+
|
||
+ 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).
|
||
+
|
||
+ 현장음의 현재 privacy screening 구간은 mall 36–48초, market 24–36초, cafe 0–12초입니다.
|
||
+ 정확한 최종 WAV의 tiny-model VAD 검사에서 세 구간 모두 segment 0개로 `requiredEmptyPassed=true`입니다.
|
||
+ 이전 실패와 no-VAD 환각 가능 text 증거는 보존했으며 no-VAD 결과는 지정 VAD gate를 대체하지 않습니다.
|
||
+ 빈 결과도 말소리·음악의 부재를 증명하지 않습니다. 독립적인 사람 청취 인증과 법적/privacy clearance는 수행하지 않았지만,
|
||
+ 제품 소유자 Oz가 2026-10-14 제공된 8개 최종 WAV 검수 묶음을 기준으로 **현재 자산 그대로 릴리스를 진행하도록 승인**했습니다.
|
||
+ 설정·해시·한계와 보존 경로는 [오디오 provenance](docs/AUDIO_PROVENANCE.md#최종-privacy-excerpt-refinement)를 참조하세요.
|
||
+
|
||
+ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
|
||
+ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
|
||
+
|
||
+ 현장음 원본은 `/tmp/findword-ambience-sources`에서만 보관하고 배포하지 않습니다.
|
||
+ `python3 scripts/ambience_audio.py`는 승인 계획의 `expectedSourceSha256`·`sourceBytes`를 전수 확인한 뒤에만 변환합니다.
|
||
+ 직접 미디어 길이는 `directMediaDurationSeconds`, 검증 상태는 `sourceVerification`에 보존합니다.
|
||
+ 8 WAV·manifest·고지를 같은 파일시스템에서 staging/검증한 뒤 Linux `renameat2(RENAME_EXCHANGE)`로
|
||
+ 폴더 전체를 교체합니다. symlink 출력은 거부하며 commit 전 실패/중단은 이전 폴더를 보존합니다.
|
||
+ PD·CC0·CC BY 4.0·CC BY-SA 4.0의 정확한 출처·저작자·변환·해시는
|
||
+ [현장음 manifest](public/ambience/manifest.json), [배포 고지](public/ambience/AMBIENCE_NOTICES.md)에 있습니다.
|
||
+ 시장 파생본은 CC BY-SA 4.0을 유지합니다. 외부 링크는 출처 확인용이며 앱은 이를 요청하지 않습니다.
|
||
+
|
||
+ - [변경 이력](CHANGELOG.md)
|
||
+ - [v0.2.3 구현·검증 결과](.agent/RESULT-v0.2.3.md)
|
||
+ - [설계와 상태 전이](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:72:20
|
||
70| it("README는 현재 리뷰 증거 경로와 정확한 라운드 보너스를 안내한다", () => {
|
||
71| const doc = readFileSync("README.md", "utf8");
|
||
72| expect.soft(doc).toContain(".agent/evidence-v0.2.5/");
|
||
| ^
|
||
73| expect.soft(doc).toContain("문항별 최대 20점, 라운드 합계 최대 30점");
|
||
74| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[19/20]⎯
|
||
|
||
|
||
Test Files 4 failed (4)
|
||
Tests 8 failed | 34 passed (42)
|
||
Start at 10:22:57
|
||
Duration 6.43s (tests 59%, environment 25%, setup 10%, transform 3%, import 2%, worker 1%)
|
||
|