RUN v5.0.4 /home/hermes/worktrees/find-word-v0.2.5 ❯ tests/assets.test.ts (2 tests | 1 failed) 429ms ❯ 저장한 한국어 neural 음성 자산 (2) × 0.2.5 neural provider·voice·prosody와 실측 도구 버전을 기록한다 16ms ❯ tests/neural-release.test.ts (4 tests | 3 failed) 100ms × 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다 75ms × 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다 15ms × README는 현재 리뷰 증거 경로와 정확한 라운드 보너스를 안내한다 4ms ❯ tests/audio.test.ts (13 tests | 3 failed) 393ms ❯ 오디오 재생 수명과 믹서 (8) × intro와 목표 실제 fetch URL에 같은 명시적 cache-busting 버전을 붙인다 59ms × intro 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다 35ms × target 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다 61ms ❯ tests/App.test.tsx (23 tests | 1 failed) 4544ms ❯ 시작부터 결과까지 사용자 흐름 (13) × 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다 53ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 8 ⎯⎯⎯⎯⎯⎯⎯ FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다 TestingLibraryElementError: Unable to find an element with the text: v0.2.5. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style  
  본문으로 건너뛰기          

 단어를 맞춰라 

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

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

   한 라운드  3 개의 질문   5단계 도전   나만의 속도로         에스의 사운드 룸   8 가지 환경      ♪ 
  ✳   행운의 단어를 찾아보거라!         S                 저장한 한국어 neural 음성 자산 > 0.2.5 neural provider·voice·prosody와 실측 도구 버전을 기록한다 AssertionError: expected '0.2.3' to be '0.2.5' // Object.is equality Expected: "0.2.5" Received: "0.2.3" ❯ tests/assets.test.ts:39:35 37| describe("저장한 한국어 neural 음성 자산", () => { 38| it("0.2.5 neural provider·voice·prosody와 실측 도구 버전을 기록한다", () => { 39| expect.soft(manifest.version).toBe("0.2.5"); | ^ 40| expect.soft(manifest.provider).toBe("Microsoft Edge online TTS"); 41| expect.soft(manifest.voice).toBe("ko-KR-HyunsuMultilingualNeural"); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/20]⎯ FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > intro와 목표 실제 fetch URL에 같은 명시적 cache-busting 버전을 붙인다 AssertionError: expected [ '/audio/intro.wav?v=0.2.3', …(2) ] to deeply equal [ '/audio/intro.wav?v=0.2.5', …(2) ] - Expected + Received [ - "/audio/intro.wav?v=0.2.5", - "/audio/repeat-market.wav?v=0.2.5", + "/audio/intro.wav?v=0.2.3", + "/audio/repeat-market.wav?v=0.2.3", "/ambience/cafe.wav?v=0.2.3", ] ❯ tests/audio.test.ts:99:54 97| f.sources.at(-1)!.onended?.(); 98| await done; 99| expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([ | ^ 100| "/audio/intro.wav?v=0.2.5", 101| `/audio/${question.id}.wav?v=0.2.5`, ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/20]⎯ FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > intro 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다 FAIL tests/audio.test.ts > 오디오 재생 수명과 믹서 > target 404 후 intro·목표를 동일한 versioned URL로 다시 요청한다 AssertionError: expected [ '/audio/intro.wav?v=0.2.3', …(5) ] to deeply equal [ '/audio/intro.wav?v=0.2.5', …(5) ] - Expected + Received [ - "/audio/intro.wav?v=0.2.5", - "/audio/repeat-market.wav?v=0.2.5", + "/audio/intro.wav?v=0.2.3", + "/audio/repeat-market.wav?v=0.2.3", "/ambience/cafe.wav?v=0.2.3", - "/audio/intro.wav?v=0.2.5", - "/audio/repeat-market.wav?v=0.2.5", + "/audio/intro.wav?v=0.2.3", + "/audio/repeat-market.wav?v=0.2.3", "/ambience/cafe.wav?v=0.2.3", ] ❯ tests/audio.test.ts:133:56 131| f.sources.at(-1)!.onended?.(); 132| await done; 133| expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([ | ^ 134| "/audio/intro.wav?v=0.2.5", 135| `/audio/${question.id}.wav?v=0.2.5`, ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/20]⎯ FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다 AssertionError: expected '0.2.3' to be '0.2.5' // Object.is equality Expected: "0.2.5" Received: "0.2.3" ❯ tests/neural-release.test.ts:9:28 7| const pkg = JSON.parse(readFileSync("package.json", "utf8")); 8| const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); 9| expect.soft(pkg.version).toBe("0.2.5"); | ^ 10| expect.soft(lock.version).toBe("0.2.5"); 11| expect.soft(lock.packages[""].version).toBe("0.2.5"); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/20]⎯ FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다 AssertionError: expected '0.2.3' to be '0.2.5' // Object.is equality Expected: "0.2.5" Received: "0.2.3" ❯ tests/neural-release.test.ts:10:29 8| const lock = JSON.parse(readFileSync("package-lock.json", "utf8")); 9| expect.soft(pkg.version).toBe("0.2.5"); 10| expect.soft(lock.version).toBe("0.2.5"); | ^ 11| expect.soft(lock.packages[""].version).toBe("0.2.5"); 12| for (const path of [ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/20]⎯ FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다 AssertionError: expected '0.2.3' to be '0.2.5' // Object.is equality Expected: "0.2.5" Received: "0.2.3" ❯ tests/neural-release.test.ts:11:42 9| expect.soft(pkg.version).toBe("0.2.5"); 10| expect.soft(lock.version).toBe("0.2.5"); 11| expect.soft(lock.packages[""].version).toBe("0.2.5"); | ^ 12| for (const path of [ 13| "src/App.tsx", ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/20]⎯ FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다 AssertionError: src/App.tsx: expected 'import { useEffect, useRef, useState …' to contain '0.2.5' - Expected + Received - 0.2.5 + import { useEffect, useRef, useState } from "react"; + import { AudioEngine, type AudioPort } from "./audio/engine"; + import { ENVIRONMENTS, LEVELS } from "./content"; + import { + createQuestions, + gameReducer, + initialGame, + normalizeAnswer, + normalizeSnr, + ROUND_SIZE, + SNR_MIN, + SNR_MAX, + summarize, + type Action, + type GameState, + type Mode, + type Question, + } from "./game"; + + // 화면은 순수 reducer의 단계만 표시한다. 엔진과 난수 seed를 주입할 수 있어 + // 사용자 흐름을 실제 장치 없이도 재현하고 별도로 실제 브라우저에서 검증한다. + export interface AppProps { + audio?: AudioPort; + seed?: number; + } + + function SoundWave({ active = false }: { active?: boolean }) { + // 파형은 장식이며 답의 글자 수나 실제 음성 데이터를 노출하지 않는다. + return ( + + ); + } + + 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(""); + // 고정 seed의 테스트와 재도전 모두에서 새 문제 조합을 만들기 위한 라운드 번호다. + const [attempt, setAttempt] = useState(0); + // token은 오래된 완료를 무시하고 playing은 같은 클릭 흐름의 중복 재생을 막는다. + const token = useRef(0); + const playing = useRef(false); + const introDone = useRef(false); + // 한글 IME의 Enter는 글자 조합 확정에도 사용된다. 조합 중 form 제출은 + // 막고 확정 뒤 명시적인 제출만 채점하여 모바일·데스크톱 입력을 보호한다. + const composing = useRef(false); + const focusTarget = useRef(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.2.3 +
+
+ {game && game.phase !== "results" ? ( + + ) : !game ? ( + + 플레이 방법 + + ) : null} + + 소리에 집중하는 시간 +
+
+ +
+ {!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 + ? "문제 풀이를 중간에 종료했어요." + : `${question!.level}단계 문제 풀이를 마쳤어요.`} +

+

+ {game.endedEarly + ? "답한 문제만 모아 결과를 보여 드려요." + : "이번 단계의 결과를 확인하고 다음 도전을 이어 가세요."} +

+

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

+
+
+ {result.score.toLocaleString()} + 총 점수 +
+
+ {result.answered ? `${result.accuracy}%` : "—"} + + 정답률 ·{" "} + {result.answered + ? `${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.total ? `${row.correct} / ${row.total}` : "미진행"} +
+

+ 정답마다 단계 × 100점 + 연속 정답 보너스 (문항별 최대{" "} + {Math.min(ROUND_SIZE - 1, 4) * 10}점, 라운드 합계 최대{" "} + {Array.from( + { length: ROUND_SIZE }, + (_, i) => Math.min(i, 4) * 10, + ).reduce((sum, bonus) => sum + bonus, 0)} + 점) +

+ {!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.5"); | ^ 20| } 21| }); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/20]⎯ FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다 AssertionError: index.html: expected '\n + + + + + + + + + 단어를 맞춰라 · Find-Word v0.2.3 + + +
+ + + + ❯ tests/neural-release.test.ts:19:51 17| "docs/AUDIO_PROVENANCE.md", 18| ]) { 19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5"); | ^ 20| } 21| }); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/20]⎯ FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다 AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.2.3\n\n소음 속에…' to contain '0.2.5' - Expected + Received - 0.2.5 + # 단어를 맞춰라 · Find-Word v0.2.3 + + 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다. + + **Designed by OZ at Heat-T Medical Inc.** + + Canonical repository: [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word). + 프로젝트 자체는 **private / all-rights-reserved**이며 `package.json`의 + `license`는 `UNLICENSED`입니다. [LICENSE](LICENSE)를 참고하세요. + 런타임 의존성 React, ReactDOM, scheduler의 MIT 전문은 + [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 보존하며, + `public/THIRD_PARTY_NOTICES.md`가 빌드 시 `dist/THIRD_PARTY_NOTICES.md`로 복사됩니다. + 빌드 검증기는 설치된 의존성의 라이선스 전문·버전과 세 사본을 대조합니다. + + React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장, + 분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며 + 의료적 진단·치료나 효과를 주장하지 않습니다. + + ## 실행 + + Node.js 최소 버전은 **22.22.2** (`>=22.22.2`)입니다. 잠긴 jsdom 계열 의존성이 + 요구하는 지원 범위는 22.x의 22.22.2 이상, 24.x의 24.15.0 이상 또는 26.0.0 이상입니다. + 이 중 지원되는 LTS 버전을 사용하세요. `package-lock.json`을 + 포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다. + + ```bash + npm ci + npm run dev + ``` + + 표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를 + 누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다. + + ## 플레이 + + - 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다. + - 8개 실제 현장 녹음 환경 중 하나를 지정하거나 라운드마다 섞어 세 곳을 방문합니다. + - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다. + - 시작 난이도 1~5를 고르고 정확히 **3문항**을 같은 단계에서 풉니다. 단계 기본값은 **SNR +6, +3, 0, −3, −6 dB**입니다. + - 목표 음성 SNR을 **−12~+12 dB, 1 dB 간격**으로 직접 조절할 수 있습니다. 재생 중 변경은 다음 재생·재청취와 남은 문제부터 적용합니다. + 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다. + - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답 보너스는 문항별 최대 20점, 라운드 합계 최대 30점입니다 (0 + 10 + 20점, 1단계 만점 330점). + - 정상 결과에서 다음 단계의 새 3문항을 바로 시작합니다. 5단계에서는 같은 단계 재도전과 홈 복귀를 제공합니다. 수동 SNR은 다음 라운드에도 유지되고, 홈 복귀 시 선택한 시작 단계의 기본값으로 돌아갑니다. + - 언제든 **문제 풀이 종료**를 누르면 즉시 오디오를 취소하고 **응답 수·정답 수**를 보여 줍니다. 무응답은 오답으로 세지 않고 정답률은 `— / 응답 없음`, 답하지 않은 단계는 `미진행`으로 표시합니다. 결과에서 **처음 화면으로** 돌아갑니다. + - 결과의 SNR은 실제 마지막 청취값을 기록합니다. 점수·정답률·최고 연속 정답·단계별 결과를 확인합니다. + - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다. + + 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과 + 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은 + [WORDS.md](docs/WORDS.md)에 설명했습니다. + 청취 과제는 직접 목표 식별, 연습 암호와 진짜 암호 구별, 앞 단어를 잊고 다음 + 단어 기억, 첫째·마지막·가운데 선택, 반복 찾기, 정정 반영, 신호 조건 적용, + 오른쪽 표지 선택의 10가지입니다. 문장에 실제 보기의 오답도 포함됩니다. + + ## 검증과 정적 빌드 + + ```bash + npm run lint + npm test + python3 -m unittest discover -s tests/python -v + npm run build + npm audit + npm run preview + ``` + + `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기 + 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개와 현장음 8개가 manifest 해시 그대로 + 포함되었는지 확인합니다. 앱 콘텐츠의 예상 101개 목록과 실제 파일 집합을 독립 비교해 + manifest와 폴더를 함께 줄이거나 파일을 추가한 경우도 거부합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다. + 런타임 MIT notice와 현장음 attribution/license notice가 `dist/`에 전문 그대로 포함되는지도 검증합니다. + + 실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증: + + ```bash + # Chromium이 없는 개발 환경에서 한 번만 실행합니다. + npx playwright install chromium + npm run test:browser + # 이미 설치된 Chrome을 사용할 때의 대안입니다. + CHROME_PATH=/usr/bin/google-chrome npm run test:browser + ``` + + Chromium sandbox는 기본으로 켭니다. sandbox를 지원하지 않는 격리된 개발 + 환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`을 명시해 끌 수 있습니다. `0`, `false`, + 빈 값이나 미설정은 sandbox를 유지합니다. `CHROME_PATH` 지정은 sandbox를 끄지 않습니다. + + 브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요. + 실제 음성 재생이 끝나기를 기다려 몇 분이 걸립니다. 로컬 4173 포트를 + 테스트 중에만 열고 종료합니다. 스크린샷과 109개 WAV의 query URL·해시·디코드 + 결과는 `test-results/`에 저장합니다. 현재 리뷰 인수 기록은 + `.agent/evidence-v0.2.3/review-fixes/`에 보존합니다. 기존 v0.1/v0.1.1 및 v0.2.3 증거는 덮어쓰지 않습니다. + + ## 음성과 문서 + + 한국어 WAV 101개는 직접 쓴 문장을 Microsoft Edge online TTS의 + `ko-KR-HyunsuMultilingualNeural`로 v0.1.1에서 생성했으며 WAV 바이트는 그대로 보존했습니다. edge-tts **7.3.1**, + rate `-6%`, pitch `-4Hz`이며 FFmpeg로 **24kHz mono PCM16**으로 변환합니다. + 생성 시 네트워크가 필요하며 앱은 저장된 WAV만 재생합니다. 환경음은 공개 라이선스의 실제 현장 녹음 8개를 각 12초로 변환해 번들합니다. 생성 음성의 독점 권리를 주장하지 않습니다. + + intro와 목표 WAV는 모두 `/audio/파일명.wav?v=0.2.3`로 요청합니다. + `src/audio/assets.ts`의 자산 버전 한 곳을 사용하며, 같은 파일명으로 음성을 + 교체할 때 이 값을 갱신해 기존 7일 캐시와 구분합니다. 배경은 `/ambience/환경.wav?v=0.2.3`입니다. 제품 버전은 0.2.3입니다. + + Linux에서 Python 3.11 이상과 FFmpeg를 준비한 뒤 다음 명령을 사용합니다. + 클라이언트 버전을 고정해도 원격 모델 변경으로 동일 바이트 재생성은 보장하지 않습니다. + + ```bash + python3 -m venv .venv-audio + .venv-audio/bin/python -m pip install -r requirements-audio.txt + .venv-audio/bin/python -m pip check + npm run audio:generate + ``` + + 고정 venv의 Python이 101개 모두 임시 생성·검증하고 디렉터리를 원자 교체합니다. + 요청별 60초 제한과 최대 3회 시도를 적용하며, 실패 시 기존 자산을 보존합니다. + 청취 샘플: [안내](public/audio/intro.wav), [행운의 사과](public/audio/luck-apple.wav). + + 현장음의 현재 privacy screening 구간은 mall 36–48초, market 24–36초, cafe 0–12초입니다. + 정확한 최종 WAV의 tiny-model VAD 검사에서 세 구간 모두 segment 0개로 `requiredEmptyPassed=true`입니다. + 이전 실패와 no-VAD 환각 가능 text 증거는 보존했으며 no-VAD 결과는 지정 VAD gate를 대체하지 않습니다. + 빈 결과도 말소리·음악의 부재를 증명하지 않습니다. 독립적인 사람 청취 인증과 법적/privacy clearance는 수행하지 않았지만, + 제품 소유자 Oz가 2026-10-14 제공된 8개 최종 WAV 검수 묶음을 기준으로 **현재 자산 그대로 릴리스를 진행하도록 승인**했습니다. + 설정·해시·한계와 보존 경로는 [오디오 provenance](docs/AUDIO_PROVENANCE.md#최종-privacy-excerpt-refinement)를 참조하세요. + + 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를 + 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다. + + 현장음 원본은 `/tmp/findword-ambience-sources`에서만 보관하고 배포하지 않습니다. + `python3 scripts/ambience_audio.py`는 승인 계획의 `expectedSourceSha256`·`sourceBytes`를 전수 확인한 뒤에만 변환합니다. + 직접 미디어 길이는 `directMediaDurationSeconds`, 검증 상태는 `sourceVerification`에 보존합니다. + 8 WAV·manifest·고지를 같은 파일시스템에서 staging/검증한 뒤 Linux `renameat2(RENAME_EXCHANGE)`로 + 폴더 전체를 교체합니다. symlink 출력은 거부하며 commit 전 실패/중단은 이전 폴더를 보존합니다. + PD·CC0·CC BY 4.0·CC BY-SA 4.0의 정확한 출처·저작자·변환·해시는 + [현장음 manifest](public/ambience/manifest.json), [배포 고지](public/ambience/AMBIENCE_NOTICES.md)에 있습니다. + 시장 파생본은 CC BY-SA 4.0을 유지합니다. 외부 링크는 출처 확인용이며 앱은 이를 요청하지 않습니다. + + - [변경 이력](CHANGELOG.md) + - [v0.2.3 구현·검증 결과](.agent/RESULT-v0.2.3.md) + - [설계와 상태 전이](docs/ARCHITECTURE.md) + - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md) + - [v0.1.1 RED/GREEN 및 실제 검증 결과](.agent/RESULT-v0.1.1.md) + - [v0.1 역사 기록](.agent/RESULT.md) + + 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다. + ❯ tests/neural-release.test.ts:19:51 17| "docs/AUDIO_PROVENANCE.md", 18| ]) { 19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5"); | ^ 20| } 21| }); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/20]⎯ FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다 AssertionError: LICENSE: expected 'Find-Word v0.2.3\nPrivate / proprieta…' to contain '0.2.5' - Expected + Received - 0.2.5 + Find-Word v0.2.3 + Private / proprietary. All rights reserved. + + No license is granted to use, copy, modify, publish, distribute, sublicense, + or sell the original project code or documentation without prior + written permission from the respective rights holders. + + Third-party components remain subject to their own licenses. Their notices + are reproduced in THIRD_PARTY_NOTICES.md and accompany the distribution. + + This project does not claim exclusive rights to generated speech. + Provider terms and any third-party rights remain applicable to generated audio; + the project notice does not grant or warrant rights in that output. + See docs/AUDIO_PROVENANCE.md. + + Third-party field recordings retain the licenses and modification notices in + public/ambience/AMBIENCE_NOTICES.md (shipped as ambience/AMBIENCE_NOTICES.md). The market + derivative remains CC BY-SA 4.0; no additional restrictions apply to that audio. + ❯ tests/neural-release.test.ts:19:51 17| "docs/AUDIO_PROVENANCE.md", 18| ]) { 19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5"); | ^ 20| } 21| }); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/20]⎯ FAIL tests/neural-release.test.ts > 0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다 AssertionError: docs/AUDIO_PROVENANCE.md: expected '# Find-Word v0.2.3 오디오 출처와 재현\n\n## 포…' to contain '0.2.5' - Expected + Received - 0.2.5 + # Find-Word v0.2.3 오디오 출처와 재현 + + ## 포함 자산과 생성 설정 + + | 대상 | 생성 방식 | 원문/설정 | 배포 방식 | + | --- | --- | --- | --- | + | `public/audio/intro.wav` | Microsoft Edge online TTS neural 음성 | `src/content.ts`의 INTRO | 24kHz mono PCM16 WAV | + | 목표 음성 100개 | 같은 provider·voice | 10 패턴 × 10 단어, `renderSentence` | 같은 WAV | + | 8개 배경 환경 | 공개 라이선스 실제 현장 녹음 | `public/ambience/manifest.json` | 12초·24kHz mono PCM16 WAV | + | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS | + + voice는 **`ko-KR-HyunsuMultilingualNeural`**, rate는 **`-6%`**, pitch는 + **`-4Hz`**, 생성 클라이언트는 **edge-tts 7.3.1**이다. 직접 작성한 한국어 + 문장을 온라인 provider에 보내 MP3 응답을 받은 뒤 FFmpeg로 PCM을 만든다. + 음성 복제나 사람의 녹음 샘플을 사용하지 않는다. 웃음은 INTRO의 ‘으하하하~~’를 + 같은 voice로 합성한다. 앱은 저장한 WAV만 같은 출처에서 가져오며 런타임 외부 + TTS/API, speechSynthesis, 인증정보가 필요하지 않다. + + 청취용 샘플은 [intro.wav](../public/audio/intro.wav)와 + [luck-apple.wav](../public/audio/luck-apple.wav)이다. 101개 전체 생성 결과의 + 크기·길이 범위와 독립 검증은 [v0.1.1 RESULT](../.agent/RESULT-v0.1.1.md)에 기록한다. + `public/audio/manifest.json`은 version, provider, voice, rate, pitch, sampleRate, + 생성 시각, 실측 edge-tts/FFmpeg 버전, 파일별 원문·SHA-256·bytes·duration·RMS·peak를 담는다. + + ## 설치와 생성 + + 생성 환경은 Linux (기존 디렉터리 교체에 `renameat2(RENAME_EXCHANGE)` 필요), + Python 3.11 이상, FFmpeg, Node.js 22.22.2 이상이다. **이미 포함된 WAV를 + 빌드·실행할 때는 Python/FFmpeg/provider 연결이 필요 없다.** 생성기 계약 테스트는 + Python과 FFmpeg를 사용하되 네트워크 없이 동작한다. + + ```bash + python3 -m venv .venv-audio + .venv-audio/bin/python -m pip install -r requirements-audio.txt + .venv-audio/bin/python -m pip check + npm run audio:generate + npm test + npm run build + ``` + + `audio:generate`는 `.venv-audio/bin/python scripts/generate_audio.py`로 고정되어 + 전역 Python으로 대체되지 않는다. `requirements-audio.txt`는 edge-tts==7.3.1과 + 전이 의존성 버전을 고정한다. 생성기는 실제 설치 버전을 조회하고 7.3.1이 아니면 + 외부 요청 전에 실패한다. `.venv-audio/`는 Git과 배포물에서 제외한다. + + 원문을 Node type stripping으로 앱 데이터에서 읽고, 101개 모두 같은 파일시스템의 + `public/.audio-stage-*/`에 순차 생성한다. 각 provider 요청은 최대 60초, 최대 + 3회 시도이며 실패 사이 1초/2초를 기다린다. FFmpeg도 60초 제한이 있다. + 재시도 전 불완전 MP3를 삭제하고 변환·검증 실패는 즉시 중단한다. 로그에는 + 파일명·시도 횟수만 남기고 provider stderr/응답·쿠키·인증 헤더를 저장하지 않는다. + + 전체 WAV와 manifest를 검증한 뒤 기존 `public/audio`와 완성 디렉터리를 단일 + 원자 교환으로 바꾼다. 실패·Ctrl-C는 기존 디렉터리의 바이트를 그대로 보존한다. + 원자 교환을 지원하지 않는 파일시스템에서는 실패하며 비원자 방식으로 우회하지 않는다. + 성공 후 이전 자산과 임시 MP3를 정리한다. 강제 종료(SIGKILL)·전원 손실 때는 + 숨김 staging 폴더가 남을 수 있으나 공개 디렉터리는 교환 전 또는 후의 전체 집합이다. + 이는 전원 손실 시 저장장치 내구성 보장을 뜻하지 않는다. + + 고정 클라이언트와 설정으로 **생성 절차**를 재현할 수 있다. 원격 모델·서비스 버전은 + 고정할 수 없어 재생성 WAV의 바이트 동일성은 보장하지 않는다. 새 실행의 manifest를 + 검토해야 한다. 빌드는 이미 생성한 WAV의 해시를 보존하고 101개 모두 포함됐는지 확인한다. + + ## 레벨 처리와 검증 + + MP3를 24kHz mono signed 16-bit little-endian PCM으로 디코드한다. 파일 전체에 + 동일한 선형 gain을 적용해 RMS 0.10을 목표로 하며 증폭은 최대 2배, peak는 + 0.89로 제한한다. 동적 압축·노이즈 게이트·침묵 제거·시간 변형은 하지 않는다. + 원래 신호의 자음과 억양을 보존하면서 파일 간 레벨 차이와 피크를 완화한다. + + 생성기와 독립 자산 테스트는 101개 파일의 원문·집합·SHA-256·bytes·duration, + 24kHz mono PCM16, 1–20초 길이, RMS > 100/32768, full-scale 미도달, + 총 48MiB 미만을 검사한다. 기존 음성 단독 검사는 RMS 0.03과 +6dB에서 peak < 0.8을 + 요구한다. 0.2.3의 사용자 최대 +12dB 혼합은 세 경로 공통 감쇠로 별도 검증한다. 앱은 Web Audio에서 배경·음성을 RMS 정규화한 뒤 상대 SNR을 적용한다. + 물리적 음압이나 주파수별 청감을 보정한 값은 아니다. + + ## 도구 라이선스와 서비스 조건 + + - [edge-tts 7.3.1 LICENSE](https://github.com/rany2/edge-tts/blob/7.3.1/LICENSE): + 대부분의 코드에 LGPLv3, `srt_composer.py`에만 MIT가 적용된다. + [공식 프로젝트](https://github.com/rany2/edge-tts)는 Microsoft Edge의 온라인 + TTS를 이용하는 클라이언트다. 클라이언트 라이선스가 provider 사용·음성 출력의 + 재배포 권한을 대신 부여하지 않는다. 이 프로젝트는 클라이언트/모델을 번들하지 않는다. + - [FFmpeg 공식 라이선스](https://ffmpeg.org/legal.html): 빌드 옵션에 따라 LGPL/GPL이 + 적용된다. 실제 사용 버전은 manifest에 남기며 실행 파일은 배포하지 않는다. + - [Microsoft Services Agreement](https://www.microsoft.com/en-us/servicesagreement)의 + AI Services 등 해당 서비스 조건은 도구의 오픈소스 라이선스와 별개다. + 위 문서 §13(s)(vi)는 출력의 권리·사용 가능성을 이용자가 판단하도록 설명한다. + API 키 없이 호출된다는 사실이 상업 사용·재배포의 포괄적 허락이나 SLA를 뜻하지 않는다. + 배포자는 자신의 이용 상황에 적용되는 최신 조건을 확인해야 한다. + - 프로젝트는 생성 음성 자체에 **독점 권리를 주장하지 않는다**. [LICENSE](../LICENSE)의 + 소스·문서 권리 유보와 provider 및 제3자의 권리는 구분한다. 런타임 React, + ReactDOM, scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에 있다. + + 문서 확인일: 2026-10-14 UTC. provider의 음성 목록, rate/pitch 지원, 가용성, + 접근 제한, 조건은 바뀔 수 있다. 온라인 생성이 중단되어도 포함된 WAV의 빌드·재생에는 + 영향이 없다. 실패 시 다른 voice로 자동 대체하지 않는다. + + ## 청감과 평가의 한계 + + 자동 검증은 PCM 신호·무결성·재생 흐름을 확인한다. 한국어 발음, 유사 자모의 + 구분, 웃음의 자연스러움과 실제 사용자의 난이도는 청취 평가가 필요하다. + 대표 샘플과 게임 내 소음 혼합 상태를 직접 들어 인수 판단한다. 배경은 실제 현장 녹음의 짧은 구간이다. 이 게임의 수치는 임상 검증·치료·청력 판정용이 아니다. + + + ## v0.2.3 현장 녹음 파생본 + + 원본 선정 계약은 [.agent/AMBIENCE-SOURCES-v0.2.3.json](../.agent/AMBIENCE-SOURCES-v0.2.3.json), + 배포 바이트의 출처·직접 URL·저작자·라이선스·변환·SHA-256·bytes·duration·RMS·peak는 + [manifest](../public/ambience/manifest.json), 정확한 attribution과 변경 고지는 + [AMBIENCE_NOTICES](../public/ambience/AMBIENCE_NOTICES.md)에 있다. 이 고지는 dist에도 포함되고 UI에서 링크한다. + 여덟 원본 모두 서로 다른 현장 녹음이며 NC/ND 자산을 사용하지 않는다. 원본은 저장소/빌드에 포함하지 않는다. + + | 환경 | 저작자 | 라이선스 | 원본 구간(초) | + | --- | --- | --- | --- | + | 길거리 | cori (Cori Samuel) | PD-author | 35–47 | + | 쇼핑몰 | thore (Thore) | PD-author | 36–48 | + | 극장 | ElevatorFan2020 | CC0 1.0 | 45–57 | + | 백화점 | icmusic | CC0 1.0 | 90–102 | + | 시장 | Marie Flutre | CC BY-SA 4.0 | 24–36 | + | 지하철 | PascalHD | CC BY 4.0 | 65–77 | + | 카페 | Marble Toast | CC0 1.0 | 0–12 | + | 비 오는 거리 | Extemporalist | CC0 1.0 | 180–192 | + + Commons primary 페이지/API와 Freesound primary 페이지의 라이선스 근거를 evidence에 보존한다. + Freesound 두 원본은 지정된 preview MP3이다. 프로젝트의 비공개 라이선스를 제3자 녹음에 + 적용하지 않으며, 시장 파생 WAV는 **CC BY-SA 4.0**으로 계속 공개·수정·재배포할 수 있다. + 지하철은 **CC BY 4.0**의 PascalHD credit·변경 고지·라이선스 링크를 유지한다. + + 재현: 검증한 원본 8개를 `/tmp/findword-ambience-sources`에 놓고 + `python3 scripts/ambience_audio.py`를 실행한다. 계획의 파일별 `selectedSegmentStartSeconds`에서 12초를 선택하고, + FFmpeg로 mono 24kHz float PCM 디코드 후 양끝 50ms 선형 fade, RMS 목표 .08, + 최대 증폭 4배/peak .85 중 가장 작은 선형 gain을 적용한다. 마지막은 표준 44-byte + WAV 헤더의 PCM16이다. 시간 변형·절차적 합성·원본 음성 대체는 없다. + 원본 SHA-256과 실측 FFmpeg 버전을 함께 기록하므로 같은 원본/도구로 재현 가능하다. + + 런타임은 음성/배경 각각 RMS .03 후 상대 gain `10^(dB/20)`을 적용한다. −12~+12 dB + 범위에서도 세 경로의 최악 피크 합을 공통 감쇠하여 최대 .8로 제한한다. 양끝 fade가 + 있는 배경을 loop하고 목표 끝에서 정리한다. 전체 버퍼 RMS 기준이며 짧은 시간창별 + SNR이나 실제 음압을 보증하지 않는다. 원본 녹음의 우연한 사람 말소리는 환경의 일부다. + 자동 검사로 음악·개인 대화의 완전한 부재를 인증할 수 없으므로 사람의 최종 청취 검토가 필요하다. + + 기존 neural WAV 101개의 원문·해시는 v0.1.1 그대로다. 음성 manifest의 version은 + 배포 버전 0.2.3, `generatedForVersion`은 0.1.1이며 원래 생성 시각·도구 정보는 보존한다. + + + ### 리뷰 후 출처 고정과 원자 발행 + + 승인 계획의 `expectedSourceSha256`와 `sourceBytes`는 불변 입력이다. 생성기는 로컬 경로나 + 현재 파일에서 이 승인값을 새로 기록하지 않으며, 여덟 원본의 해시·크기를 **첫 변환 전**에 모두 검사한다. + 한 원본이라도 다르거나 누락되면 실패한다. `sourceVerification`은 검증 상태·일자·방법·보존된 증거를 가리킨다. + 임시 경로를 담던 `verifiedLocalSource` 필드는 제거했다. + + `directMediaDurationSeconds`는 **directUrl에서 받은 바로 그 바이트**의 ffprobe `format.duration`이다. + Freesound 항목은 preview MP3의 길이이며 primary 페이지 원본 길이 또는 반올림한 표시 길이가 아니다. + 배포 manifest의 `duration`과 `conversion.durationSeconds`는 파생 WAV의 길이(12초), + `selectedSegmentStartSeconds`는 파일별 불변 승인 시작점이며 생성기는 별도 hardcoded map 없이 이 값을 읽는다. + `conversion.startSeconds`는 같은 값이어야 한다. 시작점은 유한한 0 이상 수이고, 시작점+12초가 + `directMediaDurationSeconds` 이하여야 한다. 누락·음수·NaN·Infinity·문자열·bool·범위 초과는 첫 변환 전에 거부한다. 원본 전체 디코드와 실제 수치는 + [보존된 source-integrity.json](../.agent/evidence-v0.2.3/source-integrity.json)에 있다. + + 생성기는 출력 폴더와 같은 부모의 `.ambience-stage-*/ready`에서 8 WAV·manifest·`AMBIENCE_NOTICES.md`를 + 완성한다. 정확한 파일 집합, 고유 해시, 원본 계획, PCM 형식·길이·fade·RMS·peak, + 항목별 저작자/primary URL/direct URL/라이선스 링크/outputLicense/변경 설명을 다시 검사한다. + 존재하는 symlink 및 dangling symlink 출력을 모두 거부한다. Linux `renameat2(RENAME_EXCHANGE)` 한 번이 교체의 + commit 지점이며, 최초 생성은 같은 파일시스템의 rename 한 번이다. 지원하지 않는 환경에서는 실패한다. + 파일별 overlay를 하지 않으므로 오래된 파일이 남지 않고 고지도 음원과 같은 디렉터리로 교체된다. + + commit 전 변환·쓰기·검증·교환 실패 또는 중단은 이전 디렉터리를 보존한다. syscall 이후에는 완성된 새 디렉터리가 + 공개된 상태다. SIGKILL 등 강제 종료는 숨은 임시 디렉터리를 남길 수 있지만 부분 배포는 만들지 않는다. + 전원 손실에 대한 디스크 내구성이나 여러 HTTP 요청에 걸친 snapshot 격리를 주장하지 않는다. + 앞선 독립 리뷰에서는 기존 109 WAV를 재생성하지 않았다. 이후 아래 privacy refinement에서 + 전체 현장음 bundle을 원자 재생성했으며 mall/market/cafe 3개만 바이트가 바뀌었다. + **사람의 현장음 청취 인수는 미실행**이다. + + + ### 최종 privacy excerpt refinement + + 현재 선택은 **mall 36–48초, market 24–36초, cafe 0–12초**다. Neo가 동일한 + `scripts/ambience_audio.convert` 경로와 아래 ASR 설정으로 해시 고정 원본을 재검사해 제시한 + market/cafe 후보를 사용했다. 제공받은 재검사 설명은 + [selection-context.json](../.agent/evidence-v0.2.3/privacy-screening/retry-market24-cafe0/selection-context.json)에 + 별도로 기록했으며, 원본 후보 결과를 최종 WAV 검사 결과로 대신하지 않았다. + + 승인 원본 8개의 SHA-256·bytes·ffprobe 길이를 먼저 대조하고 하드닝된 생성기로 + 8 WAV·manifest·notice 전체를 staging/검증/원자 교환했다. + [생성 실측 기록](../.agent/evidence-v0.2.3/privacy-screening/retry-market24-cafe0/regeneration.json)에 + 현재 해시·RMS·peak·변환이 있다. 이번 재시도는 market/cafe 두 WAV만 변경했다. + **101개 voice, 다섯 unaffected ambience, 기존 mall 36초 WAV는 모두 바이트 동일**하다. + + 일부 Ogg 구간은 FFmpeg timestamp/seek 때문에 12초보다 몇 프레임 짧게 출력된다. + 짧은 결과일 때만 처음부터 디코드한 24kHz PCM의 `round(start × 24000)`부터 정확한 288,000개 + sample을 선택하고 출력 PTS를 sample 수로 재설정한다. silent padding이나 time stretching은 없다. + 정말 원본이 짧으면 여전히 실패하며 `conversion.seekMethod`가 실제 경로를 기록한다. + + [final-asr.json](../.agent/evidence-v0.2.3/privacy-screening/final-asr.json)은 정확한 현재 8 WAV를 + 로컬 **faster-whisper 1.2.1, tiny multilingual, CPU int8, beam_size=5, best_of=5, + 기본 Silero VAD 활성화, condition_on_previous_text=true**로 검사한 원문이다. + 모델 revision `d90ca5fe260221311c53c58e660288d3deb8d356`, 모델/VAD 파일 해시, + 라이브러리 버전·전체 설정·각 입력 해시·반환 segment/info를 저장했다. + 이전 실패 실행과 모든 decoder/VAD 인자가 같은지 스크립트가 대조한다. + **mall/market/cafe 각각 segment 0개, duration_after_vad=0.0, requiredEmptyPassed=true**다. + 다른 다섯 WAV도 segment 0개다. threshold 조절이나 반환 segment 삭제는 하지 않았다. + 테스트는 지정 시작점·true gate·VAD 설정·실제 WAV/manifest 해시와 아래 false 인수값을 함께 강제한다. + + **humanListeningAcceptance=false, legalPrivacyClearance=false**를 유지한다. + Tiny-model ASR은 screening signal일 뿐이며 빈 결과는 말소리·음악·개인 대화가 없음을 증명하지 않는다. + 출력 text/language도 환각일 수 있고 충실한 전사나 화자 식별로 주장하지 않는다. + 독립적인 사람 청취 인증은 수행하지 않았다. 다만 제품 소유자 Oz는 2026-10-14 제공된 + 최종 WAV 8개 청취 검수 묶음과 명시된 자동 검사 한계를 기준으로 현재 자산 그대로 릴리스를 + 진행하도록 승인했다. 이 승인은 제3자의 법적/privacy clearance를 대신하지 않는다. + + 이전 market 60–72초/cafe 180–192초 실행은 market 3개/cafe 1개 segment를 반환하여 + **requiredEmptyPassed=false**였으며 release acceptance 실패였다. + 그 실행의 모든 screening 파일은 읽기 전용 + [prior-attempt-market60-cafe180](../.agent/evidence-v0.2.3/privacy-screening/prior-attempt-market60-cafe180/) + 아래 바이트 그대로 보존했다. [당시 final-asr.json](../.agent/evidence-v0.2.3/privacy-screening/prior-attempt-market60-cafe180/final-asr.json)을 + 성공으로 덮어쓰지 않았고 회귀 테스트가 이전 실패 상태와 모든 보존 파일 해시를 검사한다. + + [기존 no-VAD 증거](../.agent/evidence-v0.2.3/privacy-screening/no-vad-asr.json)도 바이트 그대로 남겼다. + 이 결과는 mall 36초/market 60초/cafe 180초에서 환각일 수 있는 candidate text를 냈으며, + 새 market/cafe WAV의 검사 결과가 아니다. **no-VAD 결과는 지정된 VAD gate를 대체하거나 뒤집지 않는다.** + 두 방법 모두 말소리/음악의 부재를 증명하지 않으며 사람의 청취 인수나 법적/privacy clearance를 제공하지 않는다. + Neo의 [이전 후보 scan](../.agent/evidence-v0.2.3/privacy-screening/candidate-scan-original.json), + [이전 파생본 ASR](../.agent/evidence-v0.2.3/privacy-screening/prior-derivatives-asr-original.json), + [당시 provenance](../.agent/evidence-v0.2.3/privacy-screening/earlier-asr-provenance.json)도 변경하지 않았다. + raw JSON에 없던 설정이나 입력 연결을 사후에 있었던 것으로 주장하지 않는다. + + 새 RED → GREEN, 전체 검증과 보존 기록은 + [retry-market24-cafe0](../.agent/evidence-v0.2.3/privacy-screening/retry-market24-cafe0/), + 현재 검증 요약은 [RESULT](../.agent/RESULT-v0.2.3.md)의 마지막 절에 있다. + ❯ tests/neural-release.test.ts:19:51 17| "docs/AUDIO_PROVENANCE.md", 18| ]) { 19| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5"); | ^ 20| } 21| }); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/20]⎯ FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다 AssertionError: docs/ARCHITECTURE.md: expected '# Find-Word v0.2.3 설계\n\n## 데이터와 출제\n…' to contain '0.2.5' - Expected + Received - 0.2.5 + # Find-Word v0.2.3 설계 + + ## 데이터와 출제 + + `src/content.ts`는 패턴, 질문, 단어, 오답 규칙, 현장음 URL, 상대 dB를 서로 다른 + 배열로 보관한다. `renderSentence`가 각 패턴의 `{word}`를 정답으로, + `{distractor1}`·`{distractor2}`를 해당 보기의 오답으로 채운다. 같은 함수를 + 음성 생성기도 사용하며 런타임 문장과 WAV의 대응은 manifest 테스트가 검증한다. + + `Pattern.mechanic`은 다음 10개 고유 청취 연산을 나타낸다. 인용 단어를 활용형으로 + 바꾸지 않으므로 동사·부사 표제어가 오답으로 나와도 문장이 자연스럽다. + + | mechanic | 청취 과제 | + | --- | --- | + | `direct-target` | 한 개의 목표 단어를 직접 식별 | + | `distractor-password` | 연습 암호를 제외하고 진짜 암호 선택 | + | `ignore-then-remember` | 앞 단어를 잊고 이후에 지시된 단어 기억 | + | `first` | 세 단어 중 처음 선택 | + | `last` | 세 단어 중 마지막 선택 | + | `middle` | 세 단어 중 가운데 선택 | + | `repetition` | 오답 사이에서 두 번 등장한 단어 찾기 | + | `correction` | 처음 답을 취소하고 정정된 답 선택 | + | `conditional-cue` | 초록/빨강 규칙과 현재 신호를 함께 적용 | + | `spatial-right-cue` | 좌우 표지 중 오른쪽에 해당하는 단어 선택 | + + 방향 단서는 문장의 의미이며 stereo 위치 재현 과제는 아니다. 모든 음성은 mono다. + 직접 식별을 제외한 문장은 실제 보기의 경쟁 단어도 발화하므로, 들린 단어 하나를 + 무조건 고르는 것으로 해결되지 않는다. 테스트는 100개 문장 전체에서 단어 순서, + 목표 반복 횟수, 취소·조건·방향 단서와 질문 의도를 검증한다. + + `src/game.ts`의 `createQuestions`는 seed 기반 PRNG와 Fisher–Yates로 + 유형·단어·정답 위치를 섞는다. 한 라운드는 3문항이며 유형과 정답 단어가 중복되지 않는다. 시작 단계는 1~5로 + 정규화하며 세 문항에 같은 level/db를 적용한다. 혼합 모드는 선택/입력/선택이다. + 환경 순환은 seed로 여덟 곳을 섞어 세 곳을 선택한다. 100개 문장 조합에 응답 방식·환경·난이도를 독립적으로 결합한다. + + 오답의 `rule`은 `RULE_DIFFICULTY`에 연결된다. 음절 교체 1, 초성 교체 2, + 모음 교체 3, 받침 교체 4는 편집상 예상 혼동 순서이며 검증된 청각 척도가 + 아니다. 모든 문항에 서로 다른 세 변형을 포함한다. 음량 단계는 이 값과 분리된다. + + ## 게임 상태 + + ```mermaid + stateDiagram-v2 + [*] --> ready + ready --> loading: PLAY + loading --> playing: STARTED + loading --> ready: ERROR + playing --> ready: ERROR / 중단 + playing --> answer: ENDED + answer --> loading: 다시 듣기 + answer --> feedback: ANSWER + feedback --> ready: NEXT (다음 문제) + feedback --> results: NEXT (세 번째 문제) + ready --> results: EXIT + loading --> results: EXIT + playing --> results: EXIT + answer --> results: EXIT + feedback --> results: EXIT + ``` + + 정상 결과는 다음 단계(최대 5)의 새 라운드로 연결되며 중도 종료는 응답 수·정답 수를 + 표시한 뒤 홈으로 연결된다. 무응답 정답률은 `— / 응답 없음`, 응답하지 않은 단계는 `미진행`으로 표시한다. + 중도 종료 제목과 현재 단계 완료 제목을 구분한다. 진행 막대는 답변 수가 아닌 현재 문항의 1/3, 2/3, 3/3이다. + 결과 단계는 모든 지연 이벤트를 무시한다. + SNR은 −12~+12 dB 정수로 제한하고 NaN/Infinity는 단계 기본값으로 복구한다. + 수동 SNR은 재생 도중 현재 gain을 바꾸지 않고 현재 재청취/남은 문항/다음 라운드에 + 적용한다. `heardDb`는 실제 재생 시작값을 보존해 결과를 정확히 기록한다. 홈은 + 수동 SNR·답·입력·IME·토큰을 초기화하고 시작 단계·모드·환경·전체 음량은 유지한다. + + 허용되지 않은 이벤트는 동일 상태를 반환한다. 재생 전/중에는 채점할 수 없고, + 피드백 이후의 중복 답변·다음 클릭도 한 번만 반영된다. UI에서도 버튼 비활성화와 + 동기 ref 잠금을 함께 사용한다. 세션 토큰으로 처음 화면 복귀·unmount 뒤 완료된 + Promise를 무시한다. 질문과 보기는 재생 완료 후 DOM에 생성되며 정답 문장은 + 채점 후에만 표시된다. + + 주관식은 NFC 한글 정규화 후 공백과 유니코드 문장부호만 제거한다. 비어 있는 + 답변과 부분 일치는 정답으로 인정하지 않는다. React 텍스트 노드로만 출력하여 + 입력을 HTML로 해석하지 않는다. + + 점수는 정답 시 `100 × 단계 + 10 × min(연속 정답 - 1, 4)`이다. 오답은 0점, + 현재 연속 정답을 초기화한다. 기존 공식의 cap은 40점이며 세 문항 라운드에서는 문항별 최대 20점, 라운드 합계 최대 30점(0 + 10 + 20)이다. 1단계 세 정답은 330점이다. 최고 연속 정답과 난이도별 정답 수는 유지한다. + + ## 오디오 + + 101개 저장 WAV는 edge-tts 7.3.1의 `ko-KR-HyunsuMultilingualNeural`로 + 빌드 전에 생성한다. 24kHz mono PCM16이며 원문·도구·해시는 manifest에 남긴다. + 생성기는 임시 디렉터리에서 전체 검증 후 원자 교체한다. 생성 절차는 + [AUDIO_PROVENANCE](AUDIO_PROVENANCE.md)에 있으며 앱은 provider에 연결하지 않는다. + + `src/audio/dsp.ts`는 순수 수치 함수이며 장치가 없어도 테스트할 수 있다. + 8개 현장 녹음 WAV를 디코드해 재생한다. 각 12초, 24kHz mono PCM16이며 양끝 + 50ms fade로 루프 접합 클릭을 줄인다. 절차적 잡음 합성 코드는 제거했다. + + `src/audio/engine.ts`는 클릭 이벤트에서 AudioContext 생성·resume을 호출한 뒤 + 같은 출처의 WAV를 로드한다. mono PCM을 decode하고 음성과 배경의 전체 버퍼 + RMS를 0.03으로 정규화한다. `gain = 10^(dB/20)`으로 목표/배경 비율을 지정한다. 정규화한 세 신호의 최대 + 피크 합을 계산해 모든 경로를 공통 감쇠하므로 합계 peak ≤ 0.8이고 SNR이 유지된다. + 시간별·주파수별 청감이나 물리적 dB SPL을 보장하는 수치는 아니다. + + `src/audio/assets.ts`는 음성 자산 버전과 URL 생성을 한 곳에서 관리한다. + intro·목표 100개·현장음 8개 모두 `?v=0.2.3`을 붙여 동일 파일명에 남은 이전 캐시를 + 피한다. 파일을 다시 생성하면 제품 버전 유지 여부와 관계없이 자산 버전을 + 갱신한다. 버전 확인을 위한 외부 요청이나 런타임 manifest 요청은 없다. + + ```text + 배경 BufferSource → 배경 Gain (공통 감쇠, 시작/끝 fade) ─┐ + 목표 BufferSource → 음성 Gain (공통 감쇠 × 10^(dB/20)) ─────┼→ master Gain → destination + 안내 BufferSource → 안내 Gain (공통 감쇠 × 1.8 → 0.12) ─────┘ + ``` + + 배경 소스는 `loop=true`이며 목표 ended에서 즉시 stop/disconnect한다. + 각 라운드의 첫 문제에서는 웃음과 안내 음성을 낮추어 배경에 묻히게 한 후 목표 문장을 + 재생한다. 이후에는 목표만 재생한다. 안내 도중 실패했으면 재시도 시 안내도 + 다시 재생한다. 기본 master는 35%이며 음량 조절은 두 신호의 비율을 유지한다. + + 동시 play는 거부한다. 정지·실패·완료는 onended 해제, 소스 stop/disconnect, + AbortController 취소, 타이머 제거를 수행한다. load/decode 뒤에도 작업 토큰을 + 검사하여 취소된 이전 작업이 소리를 시작하지 못하게 한다. 준비 15초, + 재생 길이 + 5초의 제한으로 무한 대기를 피한다. unmount는 context까지 닫는다. + + ## 화면·접근성·보안 + + `App.tsx`는 시작·플레이·결과 화면, 음량, 모드, 배경 선택을 담당한다. + 정답/피드백/결과 제목으로 초점을 이동하고 결과표에 caption에 해당하는 + 접근성 이름과 scope를 둔다. 네이티브 radio/select/button/form으로 방향키, + Tab/Enter와 터치 조작을 지원한다. 장식 파형·캐릭터는 aria-hidden이며 + reduced motion에서는 파형이 움직이지 않는다. 오류는 role=alert로 전달한다. + + 외부 폰트, CDN, 이미지, 분석 도구, 로그인, 쿠키, localStorage 사용이 없다. + 개인 입력과 점수는 현재 React 메모리에만 존재한다. 단어·정답은 클라이언트 + 번들에 있으므로 개발자 도구를 통한 정답 열람을 막는 시험 시스템은 아니다. + + ## 검증 경계 + + Vitest는 실제 RED/GREEN으로 데이터·채점·상태·오디오 그래프·화면을 검증한다. + Playwright는 production build와 실제 Chrome AudioContext로 두 크기의 3문항 + 완주·다음 단계·SNR·중도 종료·재청취·오류 복구·axe 검사를 수행한다. 테스트 자동화는 한국어 발음의 + 자연스러움, 실제 사용자의 청각 난이도, 모바일 Safari의 장치별 정책까지 + 증명하지 않으므로 인수 시 직접 청취를 추가한다. + ❯ tests/neural-release.test.ts:55:51 53| "src/audio/assets.ts", 54| ]) 55| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5"); | ^ 56| const history = readFileSync("CHANGELOG.md", "utf8"); 57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"]) ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[13/20]⎯ FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다 AssertionError: docs/DEPLOYMENT.md: expected '# Find-Word v0.2.3 정적 배포 인계\n\n이 문서는 …' to contain '0.2.5' - Expected + Received - 0.2.5 + # Find-Word v0.2.3 정적 배포 인계 + + 이 문서는 **Neo가 검토 후 수행할 절차**다. Codex는 서버, DNS, 인증정보에 + 접근하지 않았고 배포·서비스 생성·Git commit/push도 하지 않았다. + + ## 산출물 + + Node.js 최소 **22.22.2** (`>=22.22.2`)의 지원 LTS 환경에서 `npm ci`, `npm run lint`, `npm test`, + `npm run build`를 실행한다. `dist/` 전체를 하나의 릴리스로 전달한다. + `dist/audio/`의 24kHz neural WAV 101개와 version 0.2.3 manifest가 반드시 포함되어야 한다. + `dist/ambience/`의 고유 현장 WAV 8개·manifest와 `dist/ambience/AMBIENCE_NOTICES.md`도 필수다. + 런타임은 Nginx 정적 파일만 필요하며 Node 서버·TTS 엔진·DB·환경변수·API 키는 필요 없다. + 잠긴 jsdom 계열 의존성 때문에 24.x는 24.15.0 이상, 26.x는 26.0.0 이상이어야 한다. + 브라우저 검증의 Chromium sandbox는 기본 활성화다. sandbox를 지원하지 않는 + 격리된 개발 환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`로 명시적으로 끈다. + + Canonical repository는 [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word)다. + 프로젝트 자체는 private / all-rights-reserved이며 [LICENSE](../LICENSE)를 따른다. + 생성 음성은 프로젝트의 독점 권리 주장 대상이 아니며 provider 조건은 + [AUDIO_PROVENANCE](AUDIO_PROVENANCE.md)에 기록한다. + React, ReactDOM, scheduler의 MIT 전문을 담은 `dist/THIRD_PARTY_NOTICES.md`도 + 반드시 배포한다. `build` 검증기는 고지 파일이 없거나 축약·변조되면 실패한다. + + 현재 앱은 도메인 루트(`/`) 배포 기준이다. 하위 경로 배포 시 Vite base뿐 아니라 + 음성 URL도 함께 변경하고 테스트해야 한다. 예정 도메인은 `findword.heart-m.com`이다. + + ## Nginx 예시 + + 아래 root는 예시다. 실제 경로·기존 서버 블록·인증서는 Neo가 확인한다. + 기존 서비스 설정을 덮어쓰지 않고 검토 후 적용한다. + + ```nginx + # 정적 릴리스 디렉터리를 가리킨다. 인증서와 HTTPS 설정은 운영자가 별도 추가한다. + server { + listen 80; + server_name findword.heart-m.com; + root /var/www/findword/current; + index index.html; + + # 기본 MIME 정의로 JS/CSS/WAV가 올바른 타입으로 제공되게 한다. + include /etc/nginx/mime.types; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy no-referrer always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; + + # 파일명이 해시로 바뀌는 번들만 장기 캐시한다. + location /assets/ { + try_files $uri =404; + expires 1y; + } + + # 앱은 intro·목표 모두 같은 자산 버전 query를 붙여 이전 캐시와 구분한다. + # $uri는 query를 제외한 실제 WAV 경로다. 누락 시 index.html 대신 404로 응답한다. + location /audio/ { + try_files $uri =404; + expires 7d; + } + + # 현장음도 누락 시 SPA HTML로 대체하지 않는다. + location /ambience/ { + try_files $uri =404; + expires 7d; + } + + # SPA의 직접 진입·새로고침을 index.html로 받되 HTML은 재검증한다. + location / { + try_files $uri $uri/ /index.html; + expires -1; + } + } + ``` + + ## 인수 확인 + + 1. 서버에 적용하기 전 `nginx -t`, 기존 서비스 충돌, 파일 읽기 권한을 확인한다. + 2. TLS/HTTPS와 DNS를 운영자가 확인한다. 실제 도메인에서 시작 후 음성이 들리는지 확인한다. + 3. `audio/intro.wav?v=0.2.3`와 목표 WAV의 같은 버전 query가 정상 응답하고 + 존재하지 않는 오디오는 query가 있어도 404인지 확인한다. + 4. 모바일 Safari/Chrome, 데스크톱에서 음량·중단·다시 듣기·3문항 완주·다음 난이도·시작 단계·SNR 변경·중도 종료를 직접 확인한다. + 5. 원자적으로 릴리스 디렉터리를 전환하고 이전 디렉터리를 보관해 되돌릴 수 있게 한다. + + 오프라인 캐시를 제공하는 PWA는 아니다. 정적 자산의 최초 로드에는 해당 서버 + 연결이 필요하다. HTML은 재검증하고, 오디오는 URL의 자산 버전으로 구분한다. + 같은 파일명으로 WAV를 교체할 때 `src/audio/assets.ts`의 `AUDIO_ASSET_VERSION`도 + 갱신한다. 제품 버전을 유지하는 음성 수정에서도 새 자산 버전이 필요하다. + 이 query는 로컬 정적 파일 요청에만 사용하며 런타임 외부 요청을 추가하지 않는다. + + + ## 0.2.3 독립 인수와 릴리스 (Neo 수행, 이 작업에서는 미실행) + + 1. `.agent/RESULT-v0.2.3.md`와 RED/GREEN 원문, 원본/배포 해시, 라이선스 고지를 검토한다. + 2. sandboxed system Chrome desktop/mobile suite와 `npm audit`를 다시 실행한다. + 3. 검토 완료 후 feature branch push, main fast-forward, annotated `v0.2.3` tag, + fresh-clone readback으로 commit과 자산 해시를 확인한다. + 4. commit-addressed release 디렉터리로 dist를 복사하고 current 심볼릭 링크를 원자 전환한다. + 이전 release는 rollback 대상으로 보존한다. + 5. 공개 HTTPS에서 title/UI 0.2.3, WAV MIME·SHA-256·cache-busting query, 누락 자산 404, + 시작 단계·3문항 완료→다음 단계·SNR·중도 종료를 실제 Chrome으로 재검증한다. + + 이 문서의 Nginx 설정은 인계 예시이며 실행 증거가 아니다. 이번 작업은 localhost의 + production preview만 사용하며 Git commit/push/merge/tag, 외부 배포, secrets 사용은 하지 않는다. + + + v0.2.3 리뷰 검증은 `.agent/evidence-v0.2.3/review-fixes/`에 별도로 보존한다. + `check-build`는 앱 콘텐츠로 구성한 **정확히 101개**의 음성 파일명과 dist 파일 집합을 독립 대조하므로, + manifest와 디렉터리를 함께 축약하거나 추가 WAV를 넣어도 실패한다. 현장음은 승인 계획의 출처 수치와 + 각 notice 항목의 저작자·primary URL·direct URL·라이선스 링크·outputLicense·변경 고지까지 검사한다. + 현장음 notice의 배포 URL은 `/ambience/AMBIENCE_NOTICES.md`다. 고지는 음원/manifest와 함께 + 원자 교체하는 같은 디렉터리 안에 있으며, 기존 루트의 notice 사본은 제거했다. + ❯ tests/neural-release.test.ts:55:51 53| "src/audio/assets.ts", 54| ]) 55| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5"); | ^ 56| const history = readFileSync("CHANGELOG.md", "utf8"); 57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"]) ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/20]⎯ FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다 AssertionError: docs/WORDS.md: expected '# Find-Word v0.2.3 단어와 오답 변형\n\n임의의 한…' to contain '0.2.5' - Expected + Received - 0.2.5 + # Find-Word v0.2.3 단어와 오답 변형 + + 임의의 한글 조합을 자동 생성하지 않고 사전에 있는 표제어만 고른다. 다음의 + 짧은 의미 구분은 앱 작성자가 작성한 설명이며 사전의 정의·예문·음성을 + 복제한 데이터셋이 아니다. 표기 단어는 `src/content.ts`에 명시적으로 저장한다. + + | 정답 | 초성 교체 | 모음 교체 | 세 번째 변형 | + | --- | --- | --- | --- | + | 사과 | 다과 (차와 간식) | 사고 (뜻밖의 사건) | 사자 (동물), 음절 교체 | + | 바다 | 사다 (구매하다) | 보다 (눈으로 살피다) | 받다 (수령하다), 받침 추가 | + | 나무 | 나루 (배가 오가는 곳) | 너무 (정도가 지나치게) | 나물 (먹는 풀), 받침 추가 | + | 모자 | 포자 (생물의 번식 단위) | 모조 (원본을 본뜸) | 목자 (양을 돌보는 사람), 받침 추가 | + | 우산 | 부산 (도시 이름) | 오산 (잘못된 계산) | 우상 (숭배의 대상), 받침 교체 | + | 시장 | 지장 (일을 방해하는 사정) | 사장 (회사 책임자) | 신장 (몸의 키), 받침 추가 | + | 기름 | 이름 (대상을 부르는 말) | 거름 (식물에 주는 영양) | 기틀 (기본 구조), 음절 교체 | + | 소리 | 오리 (새) | 수리 (고침) | 소금 (조미료), 음절 교체 | + | 고기 | 보기 (예시) | 고개 (목 위의 머리) | 공기 (대기), 받침 추가 | + | 가구 | 하구 (강이 바다와 만나는 곳) | 가교 (연결하는 다리) | 강구 (방안을 찾음), 받침 추가 | + + 동사·부사는 사전 표제어 형태를 사용하고 부산은 사전에 등재된 고유명사로 + 사용한다. 문장의 의미 적합성을 오답 생성 규칙으로 삼지 않고 **들은 낱말의 + 소리 차이**를 묻는다. 이는 의미상 동의어가 아니라 음운상 비슷한 오답이다. + + 참조 사전은 국립국어원의 [한국어기초사전](https://krdict.korean.go.kr/kor/mainAction), + [표준국어대사전](https://stdict.korean.go.kr/main/main.do), + [우리말샘](https://opendict.korean.go.kr/)이다. 혼동하기 쉬운 일부 표제어의 + 직접 확인 링크는 [하구](https://krdict.korean.go.kr/kor/dicSearch/SearchView?ParaWordNo=85229), + [목자](https://krdict.korean.go.kr/eng/dicSearch/SearchView?ParaWordNo=55879), + [부산](https://krdict.korean.go.kr/kor/dicSearch/SearchView?ParaWordNo=68895)이다. + [국립국어원의 나루 설명](https://www.korean.go.kr/front/mcfaq/mcfaqView.do?mcfaq_seq=8815)도 참고한다. + 앱 실행 중에는 사전에 접속하지 않는다. + + `tests/content.test.ts`는 한글 완성형을 초성·중성·종성으로 분해한다. 초성·모음·받침 + 변형은 정확히 한 자모만 바뀌는지 확인한다. 음절 교체는 더 큰 차이를 허용한다. + 받침 추가도 종성 0에서 다른 값으로 바뀌는 ‘받침 교체’로 분류한다. + + `RULE_DIFFICULTY`는 음절 1, 초성 2, 모음 3, 받침 4의 예상 혼동 순서를 + 기록한다. 각 묶음의 세 오답은 서로 다른 규칙과 순위를 가진다. 이 순서는 + 출제 메타데이터이며 청감·발음·청력에 대한 실측 난이도나 임상 척도가 아니다. + 새 단어를 넣을 때는 표제어·의미·변형 규칙을 검토하고 이 목록을 갱신한다. + + 0.2.3은 기존 10가지 mechanic·100문장을 보존하며 한 라운드에서 서로 다른 세 문항을 고른다. + ❯ tests/neural-release.test.ts:55:51 53| "src/audio/assets.ts", 54| ]) 55| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5"); | ^ 56| const history = readFileSync("CHANGELOG.md", "utf8"); 57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"]) ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/20]⎯ FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다 AssertionError: public/audio/manifest.json: expected '{\n "version": "0.2.3",\n "provider…' to contain '0.2.5' - Expected + Received - 0.2.5 + { + "version": "0.2.3", + "provider": "Microsoft Edge online TTS", + "voice": "ko-KR-HyunsuMultilingualNeural", + "rate": "-6%", + "pitch": "-4Hz", + "sampleRate": 24000, + "channels": 1, + "sampleFormat": "PCM s16le", + "toolVersions": { + "edge-tts": "7.3.1", + "ffmpeg": "ffmpeg version 6.1.1-3ubuntu5 Copyright (c) 2000-2023 the FFmpeg developers" + }, + "generatedAtUtc": "2026-10-14T06:55:41.751382+00:00", + "source": "https://github.com/rany2/edge-tts", + "method": "온라인 neural 생성 MP3 → FFmpeg decode → 파일별 선형 gain → 로컬 PCM WAV", + "normalization": { + "targetRms": 0.1, + "peakCeiling": 0.89, + "maxGain": 2.0 + }, + "files": [ + { + "file": "intro.wav", + "text": "으하하하~~ 나는 에스다. 널 위한 행운의 단어를 불러주마, 잘 찾아보거라.", + "sha256": "90d9f46e6e3f9c8a0c1a781096df54d7809ef4ad38b289508ef7b8c0411465ce", + "bytes": 338732, + "duration": 7.056, + "rms": 0.09999999766232884, + "peak": 0.499847412109375 + }, + { + "file": "luck-apple.wav", + "text": "오늘의 행운을 담은 단어는 ‘사과’입니다.", + "sha256": "7c8f413f4b7e164f090e508ba090448f1376c6012649bbb6a917d45851313883", + "bytes": 188972, + "duration": 3.936, + "rms": 0.10000000059246013, + "peak": 0.401641845703125 + }, + { + "file": "luck-sea.wav", + "text": "오늘의 행운을 담은 단어는 ‘바다’입니다.", + "sha256": "47aa31d757c9fb49c9b53a1c052d90a5ced4be57549d649850d9b48f8864045b", + "bytes": 191276, + "duration": 3.984, + "rms": 0.09999997583491328, + "peak": 0.388702392578125 + }, + { + "file": "luck-tree.wav", + "text": "오늘의 행운을 담은 단어는 ‘나무’입니다.", + "sha256": "122f496834fb16d6da47f801e50e46e272e4e934af959467d1db4e490f79be7a", + "bytes": 183212, + "duration": 3.816, + "rms": 0.1000000469252297, + "peak": 0.36920166015625 + }, + { + "file": "luck-hat.wav", + "text": "오늘의 행운을 담은 단어는 ‘모자’입니다.", + "sha256": "4ff43dd042f4b5451e9fdeb3edd59f1e389a7d1dd488094587a3a9fc7dfffa44", + "bytes": 191276, + "duration": 3.984, + "rms": 0.09999997899323479, + "peak": 0.417083740234375 + }, + { + "file": "luck-umbrella.wav", + "text": "오늘의 행운을 담은 단어는 ‘우산’입니다.", + "sha256": "8d3e8e8bc97e5fe3f3362184c4abb12f01a882a1f1174d2644f0f70aac677c0a", + "bytes": 192428, + "duration": 4.008, + "rms": 0.09999996793233332, + "peak": 0.385650634765625 + }, + { + "file": "luck-market.wav", + "text": "오늘의 행운을 담은 단어는 ‘시장’입니다.", + "sha256": "356af25332bf2000ed349671f3ff39767929e1f4599af622c021f907d11fb7bf", + "bytes": 194732, + "duration": 4.056, + "rms": 0.09999998683675566, + "peak": 0.4022216796875 + }, + { + "file": "luck-oil.wav", + "text": "오늘의 행운을 담은 단어는 ‘기름’입니다.", + "sha256": "3fa1e662a3af8f2726b17b2baa5bec2934406e6595ebc243c8380dd16e190977", + "bytes": 190124, + "duration": 3.96, + "rms": 0.09999999431198445, + "peak": 0.3817138671875 + }, + { + "file": "luck-sound.wav", + "text": "오늘의 행운을 담은 단어는 ‘소리’입니다.", + "sha256": "34e4d9758d2ed448646fda364610e34779c84794403d9051ab3d8c1d33e2c80c", + "bytes": 184364, + "duration": 3.84, + "rms": 0.10000003234489044, + "peak": 0.386322021484375 + }, + { + "file": "luck-meat.wav", + "text": "오늘의 행운을 담은 단어는 ‘고기’입니다.", + "sha256": "d36dfc395a5c53fbfa4abe9980db7bb59adfdac59e40318c4aca365365e3f2ab", + "bytes": 187820, + "duration": 3.912, + "rms": 0.0999999898733683, + "peak": 0.371124267578125 + }, + { + "file": "luck-furniture.wav", + "text": "오늘의 행운을 담은 단어는 ‘가구’입니다.", + "sha256": "52cb92cdb8ee92b6f581c9d68dbc346f180c470564809ba57185429c9eb84127", + "bytes": 186668, + "duration": 3.888, + "rms": 0.10000004096194225, + "peak": 0.387054443359375 + }, + { + "file": "password-apple.wav", + "text": "연습 암호는 ‘다과’, 진짜 암호는 ‘사과’입니다.", + "sha256": "b6f5de3fe571fe3a7fdb722fe49250bcfeedef49475271e0ec76a7630907edd5", + "bytes": 235052, + "duration": 4.896, + "rms": 0.09999999690557956, + "peak": 0.483428955078125 + }, + { + "file": "password-sea.wav", + "text": "연습 암호는 ‘사다’, 진짜 암호는 ‘바다’입니다.", + "sha256": "03f45b8cb5b3dc72faf5c6e3d0e2a10cea6b60301ea400d0a4453596d2f0b337", + "bytes": 232748, + "duration": 4.848, + "rms": 0.09999999075635248, + "peak": 0.533905029296875 + }, + { + "file": "password-tree.wav", + "text": "연습 암호는 ‘나루’, 진짜 암호는 ‘나무’입니다.", + "sha256": "67da8147296e35134ec254bc82b58317630061b2c64d2d63c4be197ce8692d50", + "bytes": 233900, + "duration": 4.872, + "rms": 0.10000002225030123, + "peak": 0.490386962890625 + }, + { + "file": "password-hat.wav", + "text": "연습 암호는 ‘포자’, 진짜 암호는 ‘모자’입니다.", + "sha256": "e91bfc97df90a1675b6cd856880ac15c54de32cfc3b23603bdd1c2c5ac3ce6b8", + "bytes": 235052, + "duration": 4.896, + "rms": 0.10000000521661735, + "peak": 0.505889892578125 + }, + { + "file": "password-umbrella.wav", + "text": "연습 암호는 ‘부산’, 진짜 암호는 ‘우산’입니다.", + "sha256": "ab3ca6398c879971dc793eb25b472fcc0db72d76e386f11ef4d32c639f37c69d", + "bytes": 239660, + "duration": 4.992, + "rms": 0.10000000645104105, + "peak": 0.4776611328125 + }, + { + "file": "password-market.wav", + "text": "연습 암호는 ‘지장’, 진짜 암호는 ‘시장’입니다.", + "sha256": "02e325fb753ef7a3ee585a03d2ca2aa3f1fbc58523b062222ae1136eeed7a95c", + "bytes": 230444, + "duration": 4.8, + "rms": 0.10000002623625086, + "peak": 0.481689453125 + }, + { + "file": "password-oil.wav", + "text": "연습 암호는 ‘이름’, 진짜 암호는 ‘기름’입니다.", + "sha256": "b472957b27ff98ea7b8894fa1ff12653f7e9f66f77870faa9e52a1c1bf116c41", + "bytes": 240812, + "duration": 5.016, + "rms": 0.10000000204728039, + "peak": 0.4945068359375 + }, + { + "file": "password-sound.wav", + "text": "연습 암호는 ‘오리’, 진짜 암호는 ‘소리’입니다.", + "sha256": "044aafff08a19e39f21e58b963644715a987cd155896998769ef8e9ce6449628", + "bytes": 236204, + "duration": 4.92, + "rms": 0.10000000001020605, + "peak": 0.46209716796875 + }, + { + "file": "password-meat.wav", + "text": "연습 암호는 ‘보기’, 진짜 암호는 ‘고기’입니다.", + "sha256": "b009caadd56350d6a641ff17f0973e2b2724c183e1f45ae0bf3c483e237fa99e", + "bytes": 240812, + "duration": 5.016, + "rms": 0.10000000456206941, + "peak": 0.49786376953125 + }, + { + "file": "password-furniture.wav", + "text": "연습 암호는 ‘하구’, 진짜 암호는 ‘가구’입니다.", + "sha256": "fb2b631dd4e12120d08a84cce6450f9cd9cd68105d13d7001d808e7deaa1df4a", + "bytes": 233900, + "duration": 4.872, + "rms": 0.10000001899065697, + "peak": 0.483642578125 + }, + { + "file": "remember-apple.wav", + "text": "‘다과’. 이 단어는 잊으세요. 이제 ‘사과’, 이 단어를 기억하세요.", + "sha256": "1bf2aef9a9f030af1a8fd7dee8f44aa420a397b3ba99b575feeb3dc7a63ec191", + "bytes": 395180, + "duration": 8.232, + "rms": 0.10000000668138131, + "peak": 0.563568115234375 + }, + { + "file": "remember-sea.wav", + "text": "‘사다’. 이 단어는 잊으세요. 이제 ‘바다’, 이 단어를 기억하세요.", + "sha256": "5c91a36496e274d2be9c6fa8d9b506cd04f2a1b7ceb05ed1b7bb03fe7fca99d9", + "bytes": 399788, + "duration": 8.328, + "rms": 0.09999999180525658, + "peak": 0.546539306640625 + }, + { + "file": "remember-tree.wav", + "text": "‘나루’. 이 단어는 잊으세요. 이제 ‘나무’, 이 단어를 기억하세요.", + "sha256": "bc8e54f5407e8866bf1ca1ddfd4f6c63d85fee29bd9e430c7dd33e87974fc5b8", + "bytes": 409004, + "duration": 8.52, + "rms": 0.09999998112848245, + "peak": 0.54864501953125 + }, + { + "file": "remember-hat.wav", + "text": "‘포자’. 이 단어는 잊으세요. 이제 ‘모자’, 이 단어를 기억하세요.", + "sha256": "31a6a4270bf368cda419094d87a303ac3cd5c22539786cb165f8044b0b8e9d6e", + "bytes": 402092, + "duration": 8.376, + "rms": 0.09999997909968193, + "peak": 0.541778564453125 + }, + { + "file": "remember-umbrella.wav", + "text": "‘부산’. 이 단어는 잊으세요. 이제 ‘우산’, 이 단어를 기억하세요.", + "sha256": "c8988e604ba05d52597e561389541e8f9cda86e052279f1862bbf8399ead314e", + "bytes": 403244, + "duration": 8.4, + "rms": 0.10000001748273872, + "peak": 0.557830810546875 + }, + { + "file": "remember-market.wav", + "text": "‘지장’. 이 단어는 잊으세요. 이제 ‘시장’, 이 단어를 기억하세요.", + "sha256": "d89792da51c83ac49d27e55aa6d822a338899a5ee487b34b552748201e1522f6", + "bytes": 398636, + "duration": 8.304, + "rms": 0.09999999983200249, + "peak": 0.57281494140625 + }, + { + "file": "remember-oil.wav", + "text": "‘이름’. 이 단어는 잊으세요. 이제 ‘기름’, 이 단어를 기억하세요.", + "sha256": "3ca98682ff06d1d6b8af02c0fd5b2124f42266ac989884223eff4a9bbde1287a", + "bytes": 399788, + "duration": 8.328, + "rms": 0.09999999241389293, + "peak": 0.524383544921875 + }, + { + "file": "remember-sound.wav", + "text": "‘오리’. 이 단어는 잊으세요. 이제 ‘소리’, 이 단어를 기억하세요.", + "sha256": "98387ccc7aab566f6487d4c250b2128082cc527e013a6f7b7bc4d0266ac295df", + "bytes": 394028, + "duration": 8.208, + "rms": 0.10000002803973357, + "peak": 0.53778076171875 + }, + { + "file": "remember-meat.wav", + "text": "‘보기’. 이 단어는 잊으세요. 이제 ‘고기’, 이 단어를 기억하세요.", + "sha256": "9183ef90b64f5ceaf3bb0178c689231ff3e9981d6ce889124a15f2a6e2b7a344", + "bytes": 395180, + "duration": 8.232, + "rms": 0.10000003134220614, + "peak": 0.519287109375 + }, + { + "file": "remember-furniture.wav", + "text": "‘하구’. 이 단어는 잊으세요. 이제 ‘가구’, 이 단어를 기억하세요.", + "sha256": "442bdcb956f120c270e1f321feae0415c897de0183e468980fcc119b898b2a67", + "bytes": 403244, + "duration": 8.4, + "rms": 0.10000001858461878, + "peak": 0.5618896484375 + }, + { + "file": "last-apple.wav", + "text": "세 단어 중 마지막을 고르세요. ‘다과’, ‘사고’, ‘사과’.", + "sha256": "4cb2a8579fa3a7b91bb36363826ce8ca0d6d86e96e16a479e226a2802edd82f7", + "bytes": 356012, + "duration": 7.416, + "rms": 0.10000001764132661, + "peak": 0.622528076171875 + }, + { + "file": "last-sea.wav", + "text": "세 단어 중 마지막을 고르세요. ‘사다’, ‘보다’, ‘바다’.", + "sha256": "f755452e1da44674aa1df7d12edffa988091b89a394e7eb6d4a00eb721f07cbf", + "bytes": 345644, + "duration": 7.2, + "rms": 0.09999998238433916, + "peak": 0.591033935546875 + }, + { + "file": "last-tree.wav", + "text": "세 단어 중 마지막을 고르세요. ‘나루’, ‘너무’, ‘나무’.", + "sha256": "589e56d940a3c9e80ec02ae0b2dd93463fdc85ef019a753fcd0fded0752055f9", + "bytes": 382508, + "duration": 7.968, + "rms": 0.09999999432249608, + "peak": 0.58074951171875 + }, + { + "file": "last-hat.wav", + "text": "세 단어 중 마지막을 고르세요. ‘포자’, ‘모조’, ‘모자’.", + "sha256": "bdaf8fbc54df8061e9e58aa8c14b3044a6e863dc54973ee3ee6ae2c5ae1a28f1", + "bytes": 354860, + "duration": 7.392, + "rms": 0.099999998595968, + "peak": 0.550079345703125 + }, + { + "file": "last-umbrella.wav", + "text": "세 단어 중 마지막을 고르세요. ‘부산’, ‘오산’, ‘우산’.", + "sha256": "c801293a34013a8d93d3289a2cc87cd3eed83f97ba4647f5060efa00b117a9ad", + "bytes": 370988, + "duration": 7.728, + "rms": 0.09999998351512711, + "peak": 0.59765625 + }, + { + "file": "last-market.wav", + "text": "세 단어 중 마지막을 고르세요. ‘지장’, ‘사장’, ‘시장’.", + "sha256": "7857e87e9dbfe8fe849cc0b18c1d45b60916cb337328099c7a87a42c82e2821e", + "bytes": 359468, + "duration": 7.488, + "rms": 0.09999999842960891, + "peak": 0.600555419921875 + }, + { + "file": "last-oil.wav", + "text": "세 단어 중 마지막을 고르세요. ‘이름’, ‘거름’, ‘기름’.", + "sha256": "b5ce34950b8b31b1baf6ce55410797f0d8aa5478a670d6eace87cf76d134d6d3", + "bytes": 337580, + "duration": 7.032, + "rms": 0.10000002299391843, + "peak": 0.55706787109375 + }, + { + "file": "last-sound.wav", + "text": "세 단어 중 마지막을 고르세요. ‘오리’, ‘수리’, ‘소리’.", + "sha256": "ec9c49802aebeb6a8b898a1c0d8ad1daa127d2d64c905c197ed49a8e0c4eb64a", + "bytes": 354860, + "duration": 7.392, + "rms": 0.10000004316064788, + "peak": 0.5640869140625 + }, + { + "file": "last-meat.wav", + "text": "세 단어 중 마지막을 고르세요. ‘보기’, ‘고개’, ‘고기’.", + "sha256": "10f8ad4259ed8945c9da831e80b3902303c09caef96d400298599168fb1efed4", + "bytes": 346796, + "duration": 7.224, + "rms": 0.10000002519116054, + "peak": 0.537200927734375 + }, + { + "file": "last-furniture.wav", + "text": "세 단어 중 마지막을 고르세요. ‘하구’, ‘가교’, ‘가구’.", + "sha256": "5ad4f776e35439d408abed0e66567d6b5cdc80fc54d579165472e474f1238776", + "bytes": 357164, + "duration": 7.44, + "rms": 0.10000003394798516, + "peak": 0.583587646484375 + }, + { + "file": "first-apple.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘사과’, ‘다과’, ‘사고’.", + "sha256": "7c1dbcb3082055df2b8f087f7e906ec867c92f679de0894c34740c05e61a4bc1", + "bytes": 359468, + "duration": 7.488, + "rms": 0.1000000290826337, + "peak": 0.57977294921875 + }, + { + "file": "first-sea.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘바다’, ‘사다’, ‘보다’.", + "sha256": "0f0b39314c5f7d7168d4854003c0c284e7bc3438cdc4d1d1c5adab7cb5ed30e5", + "bytes": 349100, + "duration": 7.272, + "rms": 0.10000001935816175, + "peak": 0.57177734375 + }, + { + "file": "first-tree.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘나무’, ‘나루’, ‘너무’.", + "sha256": "3178230fd5850b6277a2153b438ec5289720e3db7ac929914cc213964f004bd5", + "bytes": 379052, + "duration": 7.896, + "rms": 0.09999998692384372, + "peak": 0.583343505859375 + }, + { + "file": "first-hat.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘모자’, ‘포자’, ‘모조’.", + "sha256": "17c8cde90567e7574b0c0702663bd70713054089ee920241b5444d9b7869eef5", + "bytes": 357164, + "duration": 7.44, + "rms": 0.1000000080433648, + "peak": 0.53125 + }, + { + "file": "first-umbrella.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘우산’, ‘부산’, ‘오산’.", + "sha256": "878e0bd2e6e4105f175aef51a028f6b141f3a2072be20f783193f3ba897a3ee0", + "bytes": 366380, + "duration": 7.632, + "rms": 0.10000000665263714, + "peak": 0.57000732421875 + }, + { + "file": "first-market.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘시장’, ‘지장’, ‘사장’.", + "sha256": "1e00b8f70a796cdebb0feb00c605fece7488b06915e6e036f54206f05e201e0c", + "bytes": 359468, + "duration": 7.488, + "rms": 0.10000001450369593, + "peak": 0.639068603515625 + }, + { + "file": "first-oil.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘기름’, ‘이름’, ‘거름’.", + "sha256": "f415195e0cfc7ee711d09de7519e88eab62cc8b58d70772c83c3f5c27c267819", + "bytes": 335276, + "duration": 6.984, + "rms": 0.10000000576279051, + "peak": 0.53472900390625 + }, + { + "file": "first-sound.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘소리’, ‘오리’, ‘수리’.", + "sha256": "46f6adeed928a16af0700f1e8626cf8049f124632a04a5d10604426fb06f9596", + "bytes": 353708, + "duration": 7.368, + "rms": 0.10000001446744461, + "peak": 0.518951416015625 + }, + { + "file": "first-meat.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘고기’, ‘보기’, ‘고개’.", + "sha256": "f51a37c1722fef581f7d642a4dcae7fcb9953b3c318dce971bf616cd1a81fecc", + "bytes": 345644, + "duration": 7.2, + "rms": 0.09999999288327781, + "peak": 0.51312255859375 + }, + { + "file": "first-furniture.wav", + "text": "세 단어 중 첫 번째를 고르세요. ‘가구’, ‘하구’, ‘가교’.", + "sha256": "29f007b385b7182db1d8aeccad4fc3c260df5e66e14e52a46b0ef0fcd0ff3a0a", + "bytes": 364076, + "duration": 7.584, + "rms": 0.09999997909045008, + "peak": 0.570037841796875 + }, + { + "file": "repeat-apple.wav", + "text": "두 번 들린 단어를 고르세요. ‘사과’, ‘다과’, ‘사과’, ‘사고’.", + "sha256": "cb7da4e9a1288cac64d168b241f27d7d2bc2dfecace58744f9bbf2792dfad04e", + "bytes": 397484, + "duration": 8.28, + "rms": 0.09999999222276726, + "peak": 0.641448974609375 + }, + { + "file": "repeat-sea.wav", + "text": "두 번 들린 단어를 고르세요. ‘바다’, ‘사다’, ‘바다’, ‘보다’.", + "sha256": "db2f235bee26ac804b5362e8e013b8f40c8da24fd3a34e0fb3b43c6d264feb36", + "bytes": 382508, + "duration": 7.968, + "rms": 0.10000003134647438, + "peak": 0.684326171875 + }, + { + "file": "repeat-tree.wav", + "text": "두 번 들린 단어를 고르세요. ‘나무’, ‘나루’, ‘나무’, ‘너무’.", + "sha256": "8735c3d9672561ca237fa87317beec2878492eb9063d6048d5b214423561acc1", + "bytes": 423980, + "duration": 8.832, + "rms": 0.10000000739222943, + "peak": 0.618743896484375 + }, + { + "file": "repeat-hat.wav", + "text": "두 번 들린 단어를 고르세요. ‘모자’, ‘포자’, ‘모자’, ‘모조’.", + "sha256": "6e089a42fd7e65e82eec7186d369c926fb7fdac794365b2bd7658822fd92437c", + "bytes": 396332, + "duration": 8.256, + "rms": 0.09999999984675931, + "peak": 0.616729736328125 + }, + { + "file": "repeat-umbrella.wav", + "text": "두 번 들린 단어를 고르세요. ‘우산’, ‘부산’, ‘우산’, ‘오산’.", + "sha256": "5e59ef39113c3d5e106862356d715f4cef2862ef8ee02654a97e532675c1ac6d", + "bytes": 410156, + "duration": 8.544, + "rms": 0.09999995863648034, + "peak": 0.674835205078125 + }, + { + "file": "repeat-market.wav", + "text": "두 번 들린 단어를 고르세요. ‘시장’, ‘지장’, ‘시장’, ‘사장’.", + "sha256": "68a5b899115a66ab225d610ec47b4de56cc8793657362bc38f533c06f741e4cc", + "bytes": 402092, + "duration": 8.376, + "rms": 0.09999998813966116, + "peak": 0.6822509765625 + }, + { + "file": "repeat-oil.wav", + "text": "두 번 들린 단어를 고르세요. ‘기름’, ‘이름’, ‘기름’, ‘거름’.", + "sha256": "55b2c7d6aa8bbce04edf8661882c9781da81c57f56217f99b80457a3bb895da0", + "bytes": 369836, + "duration": 7.704, + "rms": 0.0999999804882663, + "peak": 0.62396240234375 + }, + { + "file": "repeat-sound.wav", + "text": "두 번 들린 단어를 고르세요. ‘소리’, ‘오리’, ‘소리’, ‘수리’.", + "sha256": "e9d636ff7f4777f38b1ff15bebdcec6fbde95b4b4b05625a7415b4cfa0025b59", + "bytes": 395180, + "duration": 8.232, + "rms": 0.1000000073748717, + "peak": 0.6114501953125 + }, + { + "file": "repeat-meat.wav", + "text": "두 번 들린 단어를 고르세요. ‘고기’, ‘보기’, ‘고기’, ‘고개’.", + "sha256": "6599d2fac73ea616a793151b4ce6f51af673988d630bac6219a77e78ae6cb34e", + "bytes": 380204, + "duration": 7.92, + "rms": 0.10000001571010926, + "peak": 0.59210205078125 + }, + { + "file": "repeat-furniture.wav", + "text": "두 번 들린 단어를 고르세요. ‘가구’, ‘하구’, ‘가구’, ‘가교’.", + "sha256": "bf84dc6cff1f227924a3a62158d2fc18769fb0ca40b91418fa19e534dbf92f00", + "bytes": 397484, + "duration": 8.28, + "rms": 0.10000003132755503, + "peak": 0.667022705078125 + }, + { + "file": "correction-apple.wav", + "text": "정답은 ‘다과’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘사과’입니다.", + "sha256": "ccd7327a3fb54d3320d049ffdced3bc17595861cee69429c0ee97182d454195b", + "bytes": 402092, + "duration": 8.376, + "rms": 0.10000001654596266, + "peak": 0.490325927734375 + }, + { + "file": "correction-sea.wav", + "text": "정답은 ‘사다’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘바다’입니다.", + "sha256": "9233431d78ab4b144e3c33ad0cf0d49fc0fb91b29514343230ba32987b794bb3", + "bytes": 398636, + "duration": 8.304, + "rms": 0.09999997225238083, + "peak": 0.49615478515625 + }, + { + "file": "correction-tree.wav", + "text": "정답은 ‘나루’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘나무’입니다.", + "sha256": "b8e8bbe6b8b14727efd953fae9d4aed2caeb004055d52f611c6378322847edbe", + "bytes": 400940, + "duration": 8.352, + "rms": 0.10000003932190811, + "peak": 0.470611572265625 + }, + { + "file": "correction-hat.wav", + "text": "정답은 ‘포자’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘모자’입니다.", + "sha256": "f9f98c91cfbcfcb106eef7d5d3795a9cf3c3aab7e19a60f13d7dffe284d19f86", + "bytes": 398636, + "duration": 8.304, + "rms": 0.10000000019629104, + "peak": 0.470977783203125 + }, + { + "file": "correction-umbrella.wav", + "text": "정답은 ‘부산’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘우산’입니다.", + "sha256": "92b54f04884ab915783945077efd8f3bd443849a1720f72168b6348cae1f8e85", + "bytes": 406700, + "duration": 8.472, + "rms": 0.10000000137593418, + "peak": 0.479034423828125 + }, + { + "file": "correction-market.wav", + "text": "정답은 ‘지장’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘시장’입니다.", + "sha256": "b8d25b867497adcd0fe7c8b6a8a8d2e7a52965956738a83e53f58735708773f9", + "bytes": 405548, + "duration": 8.448, + "rms": 0.10000000759335076, + "peak": 0.491485595703125 + }, + { + "file": "correction-oil.wav", + "text": "정답은 ‘이름’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘기름’입니다.", + "sha256": "f6eff0cdddcad9790351491e60c53cb2a70d222033a583652f2de0b8cd6abcc7", + "bytes": 406700, + "duration": 8.472, + "rms": 0.09999998611167496, + "peak": 0.470794677734375 + }, + { + "file": "correction-sound.wav", + "text": "정답은 ‘오리’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘소리’입니다.", + "sha256": "5a603bc69979603afddcaeb6edf500a7c540b72405daeb33f5335d746b327da1", + "bytes": 397484, + "duration": 8.28, + "rms": 0.10000004224092506, + "peak": 0.469268798828125 + }, + { + "file": "correction-meat.wav", + "text": "정답은 ‘보기’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘고기’입니다.", + "sha256": "48e91b3932d3798e852041a854b3ee08c72127e092c62083a47af6b99adff241", + "bytes": 403244, + "duration": 8.4, + "rms": 0.09999999319342226, + "peak": 0.481536865234375 + }, + { + "file": "correction-furniture.wav", + "text": "정답은 ‘하구’입니다. 아니, 잘못 말했어요. 정정한 답은 ‘가구’입니다.", + "sha256": "5c004a7b2f5a4a918c9f689f1a2dcb219a620ed14313f40a9655b36dbf64948d", + "bytes": 398636, + "duration": 8.304, + "rms": 0.10000003705538822, + "peak": 0.487396240234375 + }, + { + "file": "middle-apple.wav", + "text": "세 단어 중 가운데를 고르세요. ‘다과’, ‘사과’, ‘사고’.", + "sha256": "8832f1b793ab3eedbe663875ea8dbeabb122c329bd5879437c71230b0418c3c8", + "bytes": 360620, + "duration": 7.512, + "rms": 0.1000000102385111, + "peak": 0.553558349609375 + }, + { + "file": "middle-sea.wav", + "text": "세 단어 중 가운데를 고르세요. ‘사다’, ‘바다’, ‘보다’.", + "sha256": "b6dbe0af143ea775f05bd59a3d6af348bcc770ab378ac91ea75a56e06c326edf", + "bytes": 346796, + "duration": 7.224, + "rms": 0.1000000185368972, + "peak": 0.56494140625 + }, + { + "file": "middle-tree.wav", + "text": "세 단어 중 가운데를 고르세요. ‘나루’, ‘나무’, ‘너무’.", + "sha256": "341888c75275c13eb31058f53a1d4276be0daa0a1b5a3a45522036928b83b0c1", + "bytes": 382508, + "duration": 7.968, + "rms": 0.09999997157674806, + "peak": 0.600006103515625 + }, + { + "file": "middle-hat.wav", + "text": "세 단어 중 가운데를 고르세요. ‘포자’, ‘모자’, ‘모조’.", + "sha256": "b01ef9762ac84e3eb757243bc9286c62f7d45444a821ae35ac36a8b095e1598a", + "bytes": 353708, + "duration": 7.368, + "rms": 0.09999999400343942, + "peak": 0.555206298828125 + }, + { + "file": "middle-umbrella.wav", + "text": "세 단어 중 가운데를 고르세요. ‘부산’, ‘우산’, ‘오산’.", + "sha256": "399ea2f681f755918f1117c8baba42739ceca64b307b999342f457a9235050b6", + "bytes": 368684, + "duration": 7.68, + "rms": 0.10000004859108667, + "peak": 0.569549560546875 + }, + { + "file": "middle-market.wav", + "text": "세 단어 중 가운데를 고르세요. ‘지장’, ‘시장’, ‘사장’.", + "sha256": "c855b449e0d4ace74681bfe68e5e7f0b063568827ae872017234dbe8ae119e4c", + "bytes": 356012, + "duration": 7.416, + "rms": 0.10000000455585449, + "peak": 0.594970703125 + }, + { + "file": "middle-oil.wav", + "text": "세 단어 중 가운데를 고르세요. ‘이름’, ‘기름’, ‘거름’.", + "sha256": "7a6b122b11c07da86c95fb78e2db2a70681b5219c63d6441fe56da1979ac1dc3", + "bytes": 334124, + "duration": 6.96, + "rms": 0.10000001284837577, + "peak": 0.5399169921875 + }, + { + "file": "middle-sound.wav", + "text": "세 단어 중 가운데를 고르세요. ‘오리’, ‘소리’, ‘수리’.", + "sha256": "4d00aa6114327f450a00dacbc7e7b572ebf1de4ce9dc2fe4b5505766cca71ce9", + "bytes": 353708, + "duration": 7.368, + "rms": 0.09999999876572897, + "peak": 0.534820556640625 + }, + { + "file": "middle-meat.wav", + "text": "세 단어 중 가운데를 고르세요. ‘보기’, ‘고기’, ‘고개’.", + "sha256": "d416107cc05e7c5b92ca27100a10a8a9689de913346179945d5542de4a6bac0c", + "bytes": 342188, + "duration": 7.128, + "rms": 0.10000002241734321, + "peak": 0.510833740234375 + }, + { + "file": "middle-furniture.wav", + "text": "세 단어 중 가운데를 고르세요. ‘하구’, ‘가구’, ‘가교’.", + "sha256": "b45b1afc8d9b534ff3e0a2a2ecb99efc1f4e40d41f89f55c4ed8ba94ad6d76d4", + "bytes": 354860, + "duration": 7.392, + "rms": 0.10000000075274348, + "peak": 0.5576171875 + }, + { + "file": "message-apple.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘다과’, ‘사과’. 지금은 빨간 불입니다.", + "sha256": "d85e6f47d871369f00de2274139e5ad6b4b5686b5e20e248df0ed6b9a320d942", + "bytes": 517292, + "duration": 10.776, + "rms": 0.10000001428889653, + "peak": 0.525360107421875 + }, + { + "file": "message-sea.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘사다’, ‘바다’. 지금은 빨간 불입니다.", + "sha256": "28a461fbe03bffe2560030dea8b60715f7c0fe262dcf85b5b7f7daf483836382", + "bytes": 517292, + "duration": 10.776, + "rms": 0.09999998804256775, + "peak": 0.53521728515625 + }, + { + "file": "message-tree.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘나루’, ‘나무’. 지금은 빨간 불입니다.", + "sha256": "5d684aa710a63d36ed8dcc2a8bfbdd5c71b0b1c63dad2a324adecb76591e85b8", + "bytes": 540332, + "duration": 11.256, + "rms": 0.09999996112285715, + "peak": 0.5262451171875 + }, + { + "file": "message-hat.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘포자’, ‘모자’. 지금은 빨간 불입니다.", + "sha256": "5a713a8522b231bb8276805c014b385b9150b79f58ff2618446607e2b442495e", + "bytes": 518444, + "duration": 10.8, + "rms": 0.10000001032745777, + "peak": 0.587005615234375 + }, + { + "file": "message-umbrella.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘부산’, ‘우산’. 지금은 빨간 불입니다.", + "sha256": "41d5866dae66600df4694a8312b531571d44cece6dba0e423e7c43d09d184819", + "bytes": 523052, + "duration": 10.896, + "rms": 0.09999985338851038, + "peak": 0.530364990234375 + }, + { + "file": "message-market.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘지장’, ‘시장’. 지금은 빨간 불입니다.", + "sha256": "9242d4cd6e0030e6b073e4d2360f84aa8a454d1749711cdb2d64c14f28a7ea20", + "bytes": 523052, + "duration": 10.896, + "rms": 0.10000000012409799, + "peak": 0.534820556640625 + }, + { + "file": "message-oil.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘이름’, ‘기름’. 지금은 빨간 불입니다.", + "sha256": "a2a71a7e5e07c81871a25c6cb7540c39aa0b32fc44f78cda66b5f34ace98d805", + "bytes": 509228, + "duration": 10.608, + "rms": 0.09999999520245731, + "peak": 0.51812744140625 + }, + { + "file": "message-sound.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘오리’, ‘소리’. 지금은 빨간 불입니다.", + "sha256": "1e682147dae8026ba78dc8f256c7f24acb3a6743bfd88a0fcc86096930eef078", + "bytes": 521900, + "duration": 10.872, + "rms": 0.09999998164749332, + "peak": 0.51953125 + }, + { + "file": "message-meat.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘보기’, ‘고기’. 지금은 빨간 불입니다.", + "sha256": "ce19735df079b54c171e775b6b365385a80160b9033442abab95b2b9c8505e1a", + "bytes": 512684, + "duration": 10.68, + "rms": 0.10000001752061703, + "peak": 0.5147705078125 + }, + { + "file": "message-furniture.wav", + "text": "초록 불이면 앞 단어, 빨간 불이면 뒤 단어입니다. ‘하구’, ‘가구’. 지금은 빨간 불입니다.", + "sha256": "26c331b7e12311236f4bb48f88918dfbf53cccd2f50b55741ea6ff4473e71dd8", + "bytes": 521900, + "duration": 10.872, + "rms": 0.09999998693144198, + "peak": 0.529327392578125 + }, + { + "file": "mission-apple.wav", + "text": "오른쪽 표지는 ‘사과’, 왼쪽 표지는 ‘다과’입니다.", + "sha256": "3ef42e276ff3500c3e197d149f29f79d6a0c1d5516ed1d5a2c57210333fded9c", + "bytes": 246572, + "duration": 5.136, + "rms": 0.10000000281248776, + "peak": 0.47015380859375 + }, + { + "file": "mission-sea.wav", + "text": "오른쪽 표지는 ‘바다’, 왼쪽 표지는 ‘사다’입니다.", + "sha256": "8838d6a1ee8a790427dc5aa761e70b3d8872af62086efeed743297ff7f8b8a06", + "bytes": 243116, + "duration": 5.064, + "rms": 0.10000001881351578, + "peak": 0.45013427734375 + }, + { + "file": "mission-tree.wav", + "text": "오른쪽 표지는 ‘나무’, 왼쪽 표지는 ‘나루’입니다.", + "sha256": "00d8e77ec0371971d632724d91f8cf8d947bfb83c582a513b6421be4c457a1da", + "bytes": 241964, + "duration": 5.04, + "rms": 0.09999999375024479, + "peak": 0.458526611328125 + }, + { + "file": "mission-hat.wav", + "text": "오른쪽 표지는 ‘모자’, 왼쪽 표지는 ‘포자’입니다.", + "sha256": "308278ec2bd27d4224976a55632d1f7fa1edabb0792126d9d858a699c287aee4", + "bytes": 245420, + "duration": 5.112, + "rms": 0.10000000114877691, + "peak": 0.4498291015625 + }, + { + "file": "mission-umbrella.wav", + "text": "오른쪽 표지는 ‘우산’, 왼쪽 표지는 ‘부산’입니다.", + "sha256": "4b4c858a25659540f9dbc524664496fc6de61ab8c1627f3cfee8c9c744e11d5b", + "bytes": 247724, + "duration": 5.16, + "rms": 0.1000000183428341, + "peak": 0.46533203125 + }, + { + "file": "mission-market.wav", + "text": "오른쪽 표지는 ‘시장’, 왼쪽 표지는 ‘지장’입니다.", + "sha256": "cefa0e95649ea6bd1df4c8dfccd2b74b4f4c028714420ce1087b61db2f2a8d47", + "bytes": 248876, + "duration": 5.184, + "rms": 0.10000000503014783, + "peak": 0.485687255859375 + }, + { + "file": "mission-oil.wav", + "text": "오른쪽 표지는 ‘기름’, 왼쪽 표지는 ‘이름’입니다.", + "sha256": "5fb63e04520c312aa6b7b48b70c2f7c140b5ab480e5c6992edbc2ba85b9a06e7", + "bytes": 241964, + "duration": 5.04, + "rms": 0.10000003066640888, + "peak": 0.443572998046875 + }, + { + "file": "mission-sound.wav", + "text": "오른쪽 표지는 ‘소리’, 왼쪽 표지는 ‘오리’입니다.", + "sha256": "659eb0e2b06e6b165b46744769743e032e2282519f8486c57bc45e0f7d07a069", + "bytes": 240812, + "duration": 5.016, + "rms": 0.10000000508879303, + "peak": 0.454803466796875 + }, + { + "file": "mission-meat.wav", + "text": "오른쪽 표지는 ‘고기’, 왼쪽 표지는 ‘보기’입니다.", + "sha256": "94f8a4f9e116a787e5ed052661d6de9ecf3ad8629db476661dd59ad03014b0c9", + "bytes": 244268, + "duration": 5.088, + "rms": 0.0999999931549985, + "peak": 0.441864013671875 + }, + { + "file": "mission-furniture.wav", + "text": "오른쪽 표지는 ‘가구’, 왼쪽 표지는 ‘하구’입니다.", + "sha256": "af82e03b686635c3356e706fe5b05c1231aeaaeab33f275aa5b8714988c65d43", + "bytes": 241964, + "duration": 5.04, + "rms": 0.09999996810995776, + "peak": 0.4852294921875 + } + ], + "generatedForVersion": "0.1.1", + "releaseNote": "0.2.3 retains all 101 original v0.1.1 neural WAV bytes; only release metadata changed." + } + ❯ tests/neural-release.test.ts:55:51 53| "src/audio/assets.ts", 54| ]) 55| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5"); | ^ 56| const history = readFileSync("CHANGELOG.md", "utf8"); 57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"]) ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/20]⎯ FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다 AssertionError: src/audio/assets.ts: expected '// WAV를 같은 파일명으로 교체하면 장기 캐시가 남는다. 배포 …' to contain '0.2.5' - Expected + Received - 0.2.5 + // WAV를 같은 파일명으로 교체하면 장기 캐시가 남는다. 배포 음원이 바뀔 때 + // 이 자산 버전도 갱신한다. 제품 버전과 별개로 관리하며 intro·목표에 함께 쓴다. + export const AUDIO_ASSET_VERSION = "0.2.3"; + + // 안내와 목표 음성의 같은 출처 경로를 만들고 공통 버전으로 캐시를 구분한다. + export function audioAssetUrl(file: string): string { + return `/audio/${file}?v=${AUDIO_ASSET_VERSION}`; + } + + // 배경 녹음도 목표 WAV와 같은 자산 버전으로 캐시를 갱신한다. + export function ambienceAssetUrl(file: string): string { + return `/ambience/${file}?v=${AUDIO_ASSET_VERSION}`; + } + ❯ tests/neural-release.test.ts:55:51 53| "src/audio/assets.ts", 54| ]) 55| expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5"); | ^ 56| const history = readFileSync("CHANGELOG.md", "utf8"); 57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"]) ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[17/20]⎯ FAIL tests/neural-release.test.ts > 현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다 AssertionError: expected '# Find-Word 변경 이력\n\n## 0.2.3 — 2026-…' to contain '0.2.5' - Expected + Received - 0.2.5 + # Find-Word 변경 이력 + + ## 0.2.3 — 2026-10-14 (로컬 구현, 배포 미실행) + + - 라운드를 정확히 3문항으로 변경하고 한 단계의 난이도를 유지한다. 시작 단계 1~5 선택, + 정상 완료 후 다음 단계 새 라운드, 5단계 재도전을 제공한다. 혼합 모드는 선택/입력을 모두 포함한다. + - SNR 직접 제어: −12~+12 dB, 1 dB 간격, 범위/NaN 정규화. 재생 중 변경은 다음 청취부터 적용, + 이후 문항/라운드에 유지하고 홈 복귀 시 단계 기본값으로 초기화한다. 양/0/음수의 의미를 설명한다. + - 중도 종료는 fetch·Web Audio·타이머를 즉시 취소하고 응답/정답 수를 보여 준 뒤 홈으로 연결한다. + 무응답을 오답으로 세지 않고 지연 콜백·중복 답변을 차단한다. + - 절차적 배경 합성을 제거하고 실제 현장 녹음 8개를 same-origin WAV로 번들한다. + 각 12초·24kHz mono PCM16·50ms fade·안전 선형 정규화·고유 SHA-256, 원본은 번들하지 않는다. + - PD/CC0/CC BY 4.0/CC BY-SA 4.0 원본을 추적하는 machine-readable manifest와 브라우저 배포 고지. + 시장 파생본의 ShareAlike와 지하철 credit/변경 고지 유지. 원본/다운로드 URL·저작자·변환·해시·실측값 기록. + - 녹음을 fetch/decode/loop하며 RMS .03 정규화 후 상대 gain을 적용한다. 공통 peak 감쇠로 SNR을 + 유지하면서 clipping headroom을 확보한다. 404/decode/timeout/abort는 재시도 가능한 한국어 안내를 제공한다. + - 회귀 수정: 세 문항 표본에 맞게 전체 콘텐츠 도달성 검사 확대; 콘텐츠의 Node 직접 import 경로 보존; + 재생 중 SNR 변경이 실제 청취 이력을 덮어쓰지 않도록 결과에 청취 시작값 기록. + - 추가 회귀 수정: 3문항에서도 순환 환경이 여덟 장소에 도달하도록 섞고, 결과의 잘못된 플레이 방법 앵커를 제거한다. + 세 문항의 문항별 보너스 상한(20점)과 라운드 합계 상한(30점)을 안내하고 새 단계/SNR 컨트롤의 44px 터치 영역을 보장한다. + 개발/preview에서 HTML Accept가 있는 누락 WAV도 SPA fallback 대신 404로 응답한다. + 모바일 시작 단계 선택값의 잘림을 막도록 중복 SNR을 별도 제어에 모았다. + 내장 SVG favicon으로 Chrome의 기본 favicon.ico 404 콘솔 오류를 제거한다. + - 기존 10 mechanic·100문장·101 neural WAV, 채점·점수·IME·재청취·음량·접근성·개인정보 미수집을 보존한다. + - package/lock/HTML/UI/manifest/문서를 0.2.3으로 정렬하고 음원 query 버전을 갱신한다. + `Designed by OZ at Heat-T Medical Inc.`와 v0.1/v0.1.1 역사 evidence는 보존한다. + - strict vertical-slice TDD RED/GREEN, Python 변환 계약, 빌드 자산/notice 검증, + sandboxed system Chrome desktop/mobile의 실제 WAV decode/playback·스크린샷·axe·네트워크 검사를 수행한다. + 정확한 실행 결과와 추가 회귀는 `.agent/RESULT-v0.2.3.md`에 기록한다. + - Nginx 현장음 404와 원자 배포/rollback 인계 문서를 갱신한다. commit/push/merge/tag/배포는 Neo 인수 이후 별도 작업이다. + + ### v0.2.3 독립 리뷰 수정 + + - 진행 막대를 현재 문항 1/3·2/3·3/3으로 정정하고 접근성 값 및 Chrome 실제 채움 픽셀을 검사한다. + - 보너스 문구를 문항별 최대 20점 / 라운드 합계 0+10+20=30점으로 정정한다. 1단계 만점 330점과 함께 검증한다. + - 무응답 정답률은 `— / 응답 없음`, 응답 없는 단계는 `미진행`; 중도 종료와 현재 단계 완료 제목을 구분한다. + - 320px·360px에서 모드 설명과 결과의 한국어 어절 분리·넘침을 방지한다. README 현재 증거 경로를 고치고 일본어 테스트 제목을 한국어로 바꾼다. + - 음성 manifest/폴더를 함께 축약해도 실패하도록 콘텐츠 기반 정확한 101개 목록, 개수 및 dist 추가/누락 파일을 검사한다. + - 현장음 승인 계획/manifest의 임시 경로를 불변 원본 해시·bytes·검증 상태 및 정확한 직접 미디어 길이로 교체한다. + - 현장음 생성 전 여덟 원본의 승인 해시·bytes를 전수 확인한다. 잘못된 원본은 첫 변환 전에 실패한다. + - 같은 파일시스템 staging에서 8 WAV·manifest·notice 전부 검증 후 Linux 원자 디렉터리 교환. stale 파일 제거, + symlink 출력 거부, 변환·쓰기·검증·교환 실패/중단 시 이전 디렉터리 보존을 테스트한다. + - 고지를 `/ambience/AMBIENCE_NOTICES.md`로 옮겨 함께 발행하고 각 항목의 creator/primary URL/direct URL/ + license URL/outputLicense/변경 설명을 빌드에서도 강제한다. UI 링크와 권리·배포·재현 문서를 갱신한다. + - 101 voice + 8 ambience WAV 바이트와 모든 이전 evidence를 보존한다. 이번 리뷰의 RED/GREEN과 전체 실행 결과는 + `.agent/evidence-v0.2.3/review-fixes/` 및 `.agent/RESULT-v0.2.3.md`를 참조한다. 사람의 청취 인수는 미실행이다. + + ### v0.2.3 최종 privacy excerpt refinement + + - `selectedSegmentStartSeconds`를 생성기의 불변 입력으로 유지하며 현재 선택을 + **mall 36–48초, market 24–36초, cafe 0–12초**로 고정한다. + 원본 해시·bytes·길이를 확인한 뒤 하드닝된 생성기로 8 WAV·manifest·notice를 원자 재생성했다. + 101 voice와 다섯 unaffected ambience 및 mall WAV는 바이트 동일하며 market/cafe만 변경했다. + - release test를 먼저 실패시켜 정확한 세 시작점, `requiredEmptyPassed=true`, VAD 설정, + `humanListeningAcceptance=false`와 `legalPrivacyClearance=false`를 강제한 뒤 새 WAV를 검사했다. + - 정확한 최종 WAV의 faster-whisper 1.2.1 tiny multilingual CPU int8, beam_size=5, best_of=5, + 기본 VAD, condition_on_previous_text=true 결과는 세 환경 모두 segment 0개, + duration_after_vad=0.0으로 지정된 VAD gate를 통과했다. 설정 조절이나 segment 삭제는 없다. + - 이전 market 60초/cafe 180초 실행은 segment 3개/1개로 실패했다. 당시 `requiredEmptyPassed=false` + 보고서와 모든 screening 파일을 `privacy-screening/prior-attempt-market60-cafe180/`에 + 읽기 전용·해시 고정 사본으로 보존한다. 이전 실패를 성공으로 다시 기록하지 않는다. + - no-VAD 환각 가능 text 증거도 그대로 보존한다. 이는 이전 파생본의 결과이고 지정 VAD gate를 + 대체하거나 뒤집지 않으며 말소리/음악의 부재도 증명하지 않는다. 독립적인 사람 청취 인증과 + 법적/privacy clearance는 미실행이지만, 제품 소유자 Oz가 2026-10-14 제공된 최종 WAV 8개 + 검수 묶음을 기준으로 **현재 자산 그대로 릴리스 진행을 승인**했다. + - 기존 Ogg sample-trim fallback·원자 교환·출처/고지 검증을 유지한다. + 새 RED/GREEN·전체 검증은 `.agent/evidence-v0.2.3/privacy-screening/retry-market24-cafe0/`, + 현재 결과는 `.agent/RESULT-v0.2.3.md` 마지막 절에 있다. Git commit/push/merge/tag/deploy는 하지 않았다. + + ## 0.1.1 — 2026-10-14 + + - Microsoft Edge online TTS `ko-KR-HyunsuMultilingualNeural`, edge-tts 7.3.1, + rate −6%, pitch −4Hz로 안내 1개·목표 100개를 새로 생성하고 24kHz mono PCM16으로 변환했다. + - pinned venv, bounded retry/timeout, 원자 자산 교환, provider/도구/출력물의 권리 구분과 provenance를 추가했다. + - query cache-busting, 시스템 Chrome sandbox 기본 활성화, 화면·음량·한국어 제목과 SNR 설명의 검증을 보강했다. + - 원본 기록: [.agent/RESULT-v0.1.1.md](.agent/RESULT-v0.1.1.md), `.agent/evidence-v0.1.1/`. + + ## 0.1.0 — 2026-10-14 (당시 UI 표기 v0.1) + + - 반응형 시작/게임/결과, 열 문항·다섯 단계, 객관식/주관식/혼합, 10 mechanic·100문장. + - 점수·연속 정답·재청취·IME 보호·접근성·동시 재생/취소/실패 복구, 여덟 합성 배경. + - 정적 SPA, 개인정보 저장·외부 런타임 요청 없음, 정확한 credit 및 private 프로젝트 권리 고지. + - 원본 기록: [.agent/RESULT.md](.agent/RESULT.md), `.agent/evidence/`. + ❯ tests/neural-release.test.ts:58:21 56| const history = readFileSync("CHANGELOG.md", "utf8"); 57| for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"]) 58| expect(history).toContain(version); | ^ 59| for (const change of [ 60| "SNR", ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[18/20]⎯ FAIL tests/neural-release.test.ts > README는 현재 리뷰 증거 경로와 정확한 라운드 보너스를 안내한다 AssertionError: expected '# 단어를 맞춰라 · Find-Word v0.2.3\n\n소음 속에…' to contain '.agent/evidence-v0.2.5/' - Expected + Received - .agent/evidence-v0.2.5/ + # 단어를 맞춰라 · Find-Word v0.2.3 + + 소음 속에 숨은 한국어 단어를 듣고 찾는 반응형 웹 게임입니다. + + **Designed by OZ at Heat-T Medical Inc.** + + Canonical repository: [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word). + 프로젝트 자체는 **private / all-rights-reserved**이며 `package.json`의 + `license`는 `UNLICENSED`입니다. [LICENSE](LICENSE)를 참고하세요. + 런타임 의존성 React, ReactDOM, scheduler의 MIT 전문은 + [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)에 보존하며, + `public/THIRD_PARTY_NOTICES.md`가 빌드 시 `dist/THIRD_PARTY_NOTICES.md`로 복사됩니다. + 빌드 검증기는 설치된 의존성의 라이선스 전문·버전과 세 사본을 대조합니다. + + React + TypeScript strict + Vite로 만든 정적 SPA입니다. 로그인, 개인정보 저장, + 분석 도구, 외부 CDN, 런타임 TTS/API 요청이 없습니다. 청각적 주의·식별 게임이며 + 의료적 진단·치료나 효과를 주장하지 않습니다. + + ## 실행 + + Node.js 최소 버전은 **22.22.2** (`>=22.22.2`)입니다. 잠긴 jsdom 계열 의존성이 + 요구하는 지원 범위는 22.x의 22.22.2 이상, 24.x의 24.15.0 이상 또는 26.0.0 이상입니다. + 이 중 지원되는 LTS 버전을 사용하세요. `package-lock.json`을 + 포함하므로 새 환경에서는 `npm ci`를 사용합니다. 이번 검증 환경은 Node 26.7.0입니다. + + ```bash + npm ci + npm run dev + ``` + + 표시되는 `http://127.0.0.1:5173`에서 사용합니다. 소리는 사용자가 **게임 시작 · 소리 듣기**를 + 누른 뒤에만 재생합니다. 낮은 음량부터 조절하고 헤드폰이나 스피커를 사용할 수 있습니다. + + ## 플레이 + + - 골고루 풀기, 보기에서 선택, 직접 입력 중 선택합니다. + - 8개 실제 현장 녹음 환경 중 하나를 지정하거나 라운드마다 섞어 세 곳을 방문합니다. + - 에스의 안내가 배경에 묻힌 뒤 목표 문장이 나옵니다. 재생 후 질문에 답합니다. + - 시작 난이도 1~5를 고르고 정확히 **3문항**을 같은 단계에서 풉니다. 단계 기본값은 **SNR +6, +3, 0, −3, −6 dB**입니다. + - 목표 음성 SNR을 **−12~+12 dB, 1 dB 간격**으로 직접 조절할 수 있습니다. 재생 중 변경은 다음 재생·재청취와 남은 문제부터 적용합니다. + 양수는 음성이 배경보다 크고, 0은 같고, 음수는 더 작다는 뜻입니다. + - 다시 듣기는 감점하지 않습니다. 정답은 `단계 × 100점`, 연속 정답 보너스는 문항별 최대 20점, 라운드 합계 최대 30점입니다 (0 + 10 + 20점, 1단계 만점 330점). + - 정상 결과에서 다음 단계의 새 3문항을 바로 시작합니다. 5단계에서는 같은 단계 재도전과 홈 복귀를 제공합니다. 수동 SNR은 다음 라운드에도 유지되고, 홈 복귀 시 선택한 시작 단계의 기본값으로 돌아갑니다. + - 언제든 **문제 풀이 종료**를 누르면 즉시 오디오를 취소하고 **응답 수·정답 수**를 보여 줍니다. 무응답은 오답으로 세지 않고 정답률은 `— / 응답 없음`, 답하지 않은 단계는 `미진행`으로 표시합니다. 결과에서 **처음 화면으로** 돌아갑니다. + - 결과의 SNR은 실제 마지막 청취값을 기록합니다. 점수·정답률·최고 연속 정답·단계별 결과를 확인합니다. + - 새로고침하면 기록이 사라집니다. 결과를 브라우저나 서버에 저장하지 않습니다. + + 10개 유형 × 10개 단어의 100개 목표 문장을 저장했습니다. 각 세션은 유형과 + 단어를 섞어 출제하며 정답 위치도 섞습니다. 사전 표제어와 변형 규칙은 + [WORDS.md](docs/WORDS.md)에 설명했습니다. + 청취 과제는 직접 목표 식별, 연습 암호와 진짜 암호 구별, 앞 단어를 잊고 다음 + 단어 기억, 첫째·마지막·가운데 선택, 반복 찾기, 정정 반영, 신호 조건 적용, + 오른쪽 표지 선택의 10가지입니다. 문장에 실제 보기의 오답도 포함됩니다. + + ## 검증과 정적 빌드 + + ```bash + npm run lint + npm test + python3 -m unittest discover -s tests/python -v + npm run build + npm audit + npm run preview + ``` + + `lint`는 ESLint와 TypeScript 검사, `test`는 unit/component/자산/빌드 검증기 + 테스트를 실행합니다. `build`는 `dist/`를 만들고 음성 101개와 현장음 8개가 manifest 해시 그대로 + 포함되었는지 확인합니다. 앱 콘텐츠의 예상 101개 목록과 실제 파일 집합을 독립 비교해 + manifest와 폴더를 함께 줄이거나 파일을 추가한 경우도 거부합니다. 빌드에는 음성 합성 도구가 필요하지 않습니다. + 런타임 MIT notice와 현장음 attribution/license notice가 `dist/`에 전문 그대로 포함되는지도 검증합니다. + + 실제 오디오·360px/1280px 화면·키보드·접근성·오류 재시도 검증: + + ```bash + # Chromium이 없는 개발 환경에서 한 번만 실행합니다. + npx playwright install chromium + npm run test:browser + # 이미 설치된 Chrome을 사용할 때의 대안입니다. + CHROME_PATH=/usr/bin/google-chrome npm run test:browser + ``` + + Chromium sandbox는 기본으로 켭니다. sandbox를 지원하지 않는 격리된 개발 + 환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`을 명시해 끌 수 있습니다. `0`, `false`, + 빈 값이나 미설정은 sandbox를 유지합니다. `CHROME_PATH` 지정은 sandbox를 끄지 않습니다. + + 브라우저 테스트는 기존 `dist/`를 사용하므로 소스 변경 후 먼저 빌드하세요. + 실제 음성 재생이 끝나기를 기다려 몇 분이 걸립니다. 로컬 4173 포트를 + 테스트 중에만 열고 종료합니다. 스크린샷과 109개 WAV의 query URL·해시·디코드 + 결과는 `test-results/`에 저장합니다. 현재 리뷰 인수 기록은 + `.agent/evidence-v0.2.3/review-fixes/`에 보존합니다. 기존 v0.1/v0.1.1 및 v0.2.3 증거는 덮어쓰지 않습니다. + + ## 음성과 문서 + + 한국어 WAV 101개는 직접 쓴 문장을 Microsoft Edge online TTS의 + `ko-KR-HyunsuMultilingualNeural`로 v0.1.1에서 생성했으며 WAV 바이트는 그대로 보존했습니다. edge-tts **7.3.1**, + rate `-6%`, pitch `-4Hz`이며 FFmpeg로 **24kHz mono PCM16**으로 변환합니다. + 생성 시 네트워크가 필요하며 앱은 저장된 WAV만 재생합니다. 환경음은 공개 라이선스의 실제 현장 녹음 8개를 각 12초로 변환해 번들합니다. 생성 음성의 독점 권리를 주장하지 않습니다. + + intro와 목표 WAV는 모두 `/audio/파일명.wav?v=0.2.3`로 요청합니다. + `src/audio/assets.ts`의 자산 버전 한 곳을 사용하며, 같은 파일명으로 음성을 + 교체할 때 이 값을 갱신해 기존 7일 캐시와 구분합니다. 배경은 `/ambience/환경.wav?v=0.2.3`입니다. 제품 버전은 0.2.3입니다. + + Linux에서 Python 3.11 이상과 FFmpeg를 준비한 뒤 다음 명령을 사용합니다. + 클라이언트 버전을 고정해도 원격 모델 변경으로 동일 바이트 재생성은 보장하지 않습니다. + + ```bash + python3 -m venv .venv-audio + .venv-audio/bin/python -m pip install -r requirements-audio.txt + .venv-audio/bin/python -m pip check + npm run audio:generate + ``` + + 고정 venv의 Python이 101개 모두 임시 생성·검증하고 디렉터리를 원자 교체합니다. + 요청별 60초 제한과 최대 3회 시도를 적용하며, 실패 시 기존 자산을 보존합니다. + 청취 샘플: [안내](public/audio/intro.wav), [행운의 사과](public/audio/luck-apple.wav). + + 현장음의 현재 privacy screening 구간은 mall 36–48초, market 24–36초, cafe 0–12초입니다. + 정확한 최종 WAV의 tiny-model VAD 검사에서 세 구간 모두 segment 0개로 `requiredEmptyPassed=true`입니다. + 이전 실패와 no-VAD 환각 가능 text 증거는 보존했으며 no-VAD 결과는 지정 VAD gate를 대체하지 않습니다. + 빈 결과도 말소리·음악의 부재를 증명하지 않습니다. 독립적인 사람 청취 인증과 법적/privacy clearance는 수행하지 않았지만, + 제품 소유자 Oz가 2026-10-14 제공된 8개 최종 WAV 검수 묶음을 기준으로 **현재 자산 그대로 릴리스를 진행하도록 승인**했습니다. + 설정·해시·한계와 보존 경로는 [오디오 provenance](docs/AUDIO_PROVENANCE.md#최종-privacy-excerpt-refinement)를 참조하세요. + + 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를 + 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다. + + 현장음 원본은 `/tmp/findword-ambience-sources`에서만 보관하고 배포하지 않습니다. + `python3 scripts/ambience_audio.py`는 승인 계획의 `expectedSourceSha256`·`sourceBytes`를 전수 확인한 뒤에만 변환합니다. + 직접 미디어 길이는 `directMediaDurationSeconds`, 검증 상태는 `sourceVerification`에 보존합니다. + 8 WAV·manifest·고지를 같은 파일시스템에서 staging/검증한 뒤 Linux `renameat2(RENAME_EXCHANGE)`로 + 폴더 전체를 교체합니다. symlink 출력은 거부하며 commit 전 실패/중단은 이전 폴더를 보존합니다. + PD·CC0·CC BY 4.0·CC BY-SA 4.0의 정확한 출처·저작자·변환·해시는 + [현장음 manifest](public/ambience/manifest.json), [배포 고지](public/ambience/AMBIENCE_NOTICES.md)에 있습니다. + 시장 파생본은 CC BY-SA 4.0을 유지합니다. 외부 링크는 출처 확인용이며 앱은 이를 요청하지 않습니다. + + - [변경 이력](CHANGELOG.md) + - [v0.2.3 구현·검증 결과](.agent/RESULT-v0.2.3.md) + - [설계와 상태 전이](docs/ARCHITECTURE.md) + - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md) + - [v0.1.1 RED/GREEN 및 실제 검증 결과](.agent/RESULT-v0.1.1.md) + - [v0.1 역사 기록](.agent/RESULT.md) + + 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다. + ❯ tests/neural-release.test.ts:72:20 70| it("README는 현재 리뷰 증거 경로와 정확한 라운드 보너스를 안내한다", () => { 71| const doc = readFileSync("README.md", "utf8"); 72| expect.soft(doc).toContain(".agent/evidence-v0.2.5/"); | ^ 73| expect.soft(doc).toContain("문항별 최대 20점, 라운드 합계 최대 30점"); 74| }); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[19/20]⎯ Test Files 4 failed (4) Tests 8 failed | 34 passed (42) Start at 10:22:57 Duration 6.43s (tests 59%, environment 25%, setup 10%, transform 3%, import 2%, worker 1%)