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