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  
  본문으로 건너뛰기          

 단어를 맞춰라 

  v0.1.1  
   플레이 방법   ↓    소리에 집중하는 시간         귀를 기울이면, 발견하는 즐거움 

  소음 속 작은 단어, 
  들리시나요?    도시의 소리 사이에 에스가 숨겨 둔 단어. 
 조금 더 귀 기울여, 오늘의 행운을 찾아보세요. 

   한 라운드  3 개의 질문   5단계 도전   나만의 속도로         에스의 사운드 룸   8 가지 환경      ♪ 
  ✳   행운의 단어를 찾아보거라!         S                 저장한 한국어 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 ( + + ); + } + + function Mascot({ small = false }: { small?: boolean }) { + // CSS로 직접 그린 캐릭터라 외부 이미지·폰트·저작권 불명 자산이 필요 없다. + return ( + + ); + } + + // 난이도의 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(() => audio ?? new AudioEngine()); + const [mode, setMode] = useState("mixed"); + const [environment, setEnvironment] = useState("rotate"); + // 시작 단계는 홈 설정, snrOverride는 사용자가 직접 바꾼 라운드 간 SNR이다. + // null이면 각 새 단계의 기본값을 사용하므로 자동 진행과 수동 선택을 구분한다. + const [startingLevel, setStartingLevel] = useState(1); + const [snrOverride, setSnrOverride] = useState(null); + const [volume, setVolume] = useState(35); + const [game, setGame] = useState(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(null); + const startButton = useRef(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 = ( +
+ + { + const db = normalizeSnr(Number(event.target.value), LEVELS[(question?.level ?? startingLevel) - 1]!.db); + setSnrOverride(db); + dispatch({ type: "SNR", db }); + }} /> + {formatSnr(selectedSnr)} +

{describeSnr(selectedSnr)} 범위 −12~+12 dB · 1 dB 간격. + 양수는 음성이 더 크게, 0은 같게, 음수는 더 작게 들려요. + 변경은 다음 재생·재청취부터 적용돼요.

+
+ ); + + // 상태와 엔진 연결은 공유하고 각 화면에는 컨트롤 하나만 배치한다. + // 위치가 바뀌어도 기존 접근성 이름·label 연결·사용자가 정한 음량을 유지한다. + const volumeControl = ( +
+ + { + const value = Number(event.target.value); + setVolume(value); + engine.setVolume(value / 100); + }} + /> + {volume}% +
+ ); + + return ( + <> + + 본문으로 건너뛰기 + +
+
+ +

단어를 맞춰라

+ v0.1.1 +
+
+ {game && game.phase !== "results" ? ( + + ) : ( + + 플레이 방법 + + )} + + 소리에 집중하는 시간 +
+
+ +
+ {!game ? ( + <> +
+
+

+ 귀를 기울이면, 발견하는 즐거움 +

+

+ 소음 속 작은 단어, +
+ 들리시나요? +

+

+ 도시의 소리 사이에 에스가 숨겨 둔 단어. +
+ 조금 더 귀 기울여, 오늘의 행운을 찾아보세요. +

+
+ 한 라운드 {ROUND_SIZE}개의 질문 + 5단계 도전 + 나만의 속도로 +
+
+
+
+ + 에스의 사운드 룸 + + {ENVIRONMENTS.length}가지 환경 +
+
+
+ + +
행운의 단어를 찾아보거라!
+ +
+ + 소음 사이, 숨겨진 한마디 +
+
+
+ +
+
+ 01 +
+

나의 소리 탐험 준비

+

한 단계에서 세 문제를 풀고, 완료 후 다음 단계에 도전해요.

+
+ 라운드당 약 1–2분 +
+
+
+ 어떻게 답할까요? +
+ {( + [ + { + value: "mixed", + name: "골고루 풀기", + sub: "선택 + 직접 입력", + icon: "◈", + }, + { + value: "choice", + name: "보기에서 선택", + sub: "4개 단어 중 하나", + icon: "☷", + }, + { + value: "text", + name: "직접 입력", + sub: "기억한 단어 쓰기", + icon: "✎", + }, + ] as const + ).map((option) => ( + + ))} +
+
+
+ + +

+ {ENVIRONMENTS.find((e) => e.id === environment) + ?.description ?? + "길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요."} +

+
+ 길거리 + 카페 + 지하철 + +5개 환경 +
+
+
+
+ + +
+ {snrControl} + {volumeControl} +
+

+ 편안한 작은 음량으로 시작해 + 보세요. +

+ +
+
+ +
+

작은 단어를 발견하는 방법

+
+
+ 01 +

먼저, 귀 기울이기

+

+ 에스의 안내와 소음 속 문장을 들어요. +
+ 한 라운드 동안 같은 난이도로 들어요. +

+
+
+ 02 +

기억한 단어 찾기

+

+ 소리가 끝나면 질문이 나타나요. +
+ 보기에서 고르거나 직접 입력해요. +

+
+
+ 03 +

나의 발견 돌아보기

+

+ 세 문제 뒤 나의 결과를 확인해요. +
+ 놓쳤다면 언제든 다시 들어도 좋아요. +

+
+
+
+ + ) : game.phase === "results" && result ? ( +
+ {game.endedEarly ? "지금까지의 소리 탐험" : "세 번의 귀 기울임, 하나의 발견"} +
+ +
+

+ {game.endedEarly ? "문제 풀이를 종료했어요." : "오늘의 소리 탐험을 마쳤어요."} +

+

+ 작은 소리에 집중한 당신, 다음 탐험에서도 만나요. +

+

응답 {result.answered}문항 · 정답 {result.correct}문항

+
+
+ {result.score.toLocaleString()} + 총 점수 +
+
+ {result.accuracy}% + 정답률 · {result.correct}/{result.answered} +
+
+ + {game.bestStreak} + 회 + + 최고 연속 정답 +
+
+ + + + + + + + + + {result.byLevel.map((row) => ( + + + + + + ))} + +
난이도SNR맞힌 문제
{row.level}단계 + {[...new Set(game.answers.filter(a => a.level === row.level).map(a => a.db))].map(formatSnr).join(", ") || formatSnr(LEVELS[row.level - 1]!.db)} + + {row.correct} / {row.total} +
+

단계 × 100점 + 연속 정답 보너스 (최대 40점)

+ {!game.endedEarly && ( + + )} + +
+ ) : question && result ? ( +
+
+ 나의 소리 탐험 + + 문제 {String(game.index + 1).padStart(2, "0")} / {game.questions.length} + +
+ +
+ +
+
+ {question.pattern.title} + + {question.mode === "choice" ? "객관식" : "주관식"} + +
+ {game.phase === "answer" ? ( + <> +

+ {question.pattern.question} +

+

+ {question.mode === "choice" + ? "가장 또렷하게 기억나는 단어를 골라 주세요." + : "기억나는 단어 하나를 입력해 주세요."} +

+ {question.mode === "choice" ? ( +
+ {question.options.map((option, i) => ( + + ))} +
+ ) : ( +
{ + event.preventDefault(); + if (composing.current) return; + dispatch({ type: "ANSWER", input }); + }} + > + + { + 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="여기에 단어를 적어 주세요" + /> + +
+ )} + + ) : game.phase === "feedback" ? ( +
+ +

+ {game.answers.at(-1)!.correct + ? "정답이에요!" + : "다음엔 찾을 수 있어요."} +

+

+ 숨겨진 단어는 {question.word.answer} +

+
{question.sentence}
+

+ 내 답: {game.answers.at(-1)!.input} · + + {game.answers.at(-1)!.points}점 +

+ +
+ ) : ( +
+ +

+ {busy + ? "소리 사이에 집중해 보세요." + : "다음 단어를 만나 볼까요?"} +

+

+ 문장이 끝나면 질문이 나타나요. +
+ 작은 단어 하나를 기억해 주세요. +

+

+ {game.phase === "loading" + ? "소리를 준비하고 있어요…" + : game.phase === "playing" + ? "에스의 목소리를 듣고 있어요…" + : "준비되면 문장 듣기를 눌러 주세요."} +

+
+ )} + {game.error && ( +
+

{game.error}

+ +
+ )} + {game.phase !== "feedback" && !game.error && ( +
+ + {busy && ( + + )} + 다시 들어도 점수는 줄어들지 않아요. +
+ )} +
+
+
+ ) : null} + {game?.phase === "results" && volumeControl} +
+
+

Designed by OZ at Heat-T Medical Inc.

+

청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다.

+ 개인정보 수집 없이, 오직 플레이만. +
+ + ); + } + ❯ 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 '\n + + + + + + + 단어를 맞춰라 · Find-Word v0.1.1 + + +
+ + + + ❯ 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