2746 lines
130 KiB
Plaintext
2746 lines
130 KiB
Plaintext
|
||
> find-word@0.1.0 test
|
||
> vitest run tests/neural-release.test.ts tests/App.test.tsx -t 0.1.1|브랜드|고정 venv
|
||
|
||
|
||
RUN v5.0.4 /home/hermes/worktrees/find-word-v0.1.1
|
||
|
||
❯ tests/neural-release.test.ts (2 tests | 2 failed) 55ms
|
||
× 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다 23ms
|
||
× 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다 30ms
|
||
❯ tests/App.test.tsx (13 tests | 1 failed | 12 skipped) 242ms
|
||
❯ 시작부터 결과까지 사용자 흐름 (13)
|
||
× 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다 241ms
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
|
||
|
||
FAIL tests/App.test.tsx > 시작부터 결과까지 사용자 흐름 > 브랜드·버전·크레딧을 보여주며 직접 시작하기 전에는 재생하지 않는다
|
||
TestingLibraryElementError: Unable to find an element with the text: v0.1.1. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
|
||
|
||
Ignored nodes: comments, script, style
|
||
[36m<body>[39m
|
||
[36m<div>[39m
|
||
[36m<a[39m
|
||
[33mclass[39m=[32m"skip-link"[39m
|
||
[33mhref[39m=[32m"#main"[39m
|
||
[36m>[39m
|
||
[0m본문으로 건너뛰기[0m
|
||
[36m</a>[39m
|
||
[36m<header[39m
|
||
[33mclass[39m=[32m"header wrap"[39m
|
||
[36m>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"brand"[39m
|
||
[36m>[39m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"brand-mark"[39m
|
||
[36m>[39m
|
||
[36m<i />[39m
|
||
[36m<i />[39m
|
||
[36m<i />[39m
|
||
[36m<i />[39m
|
||
[36m</span>[39m
|
||
[36m<h1>[39m
|
||
[0m단어를 맞춰라[0m
|
||
[36m</h1>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"version"[39m
|
||
[36m>[39m
|
||
[0mv0.1[0m
|
||
[36m</span>[39m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"header-actions"[39m
|
||
[36m>[39m
|
||
[36m<a[39m
|
||
[33mhref[39m=[32m"#howto"[39m
|
||
[36m>[39m
|
||
[0m플레이 방법 [0m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[36m>[39m
|
||
[0m↓[0m
|
||
[36m</span>[39m
|
||
[36m</a>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"little-dot"[39m
|
||
[36m/>[39m
|
||
[0m소리에 집중하는 시간[0m
|
||
[36m</div>[39m
|
||
[36m</header>[39m
|
||
[36m<main[39m
|
||
[33mclass[39m=[32m"wrap"[39m
|
||
[33mid[39m=[32m"main"[39m
|
||
[33mtabindex[39m=[32m"-1"[39m
|
||
[36m>[39m
|
||
[36m<section[39m
|
||
[33maria-labelledby[39m=[32m"hero-title"[39m
|
||
[33mclass[39m=[32m"hero"[39m
|
||
[36m>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"hero-copy"[39m
|
||
[36m>[39m
|
||
[36m<p[39m
|
||
[33mclass[39m=[32m"eyebrow"[39m
|
||
[36m>[39m
|
||
[36m<span />[39m
|
||
[0m 귀를 기울이면, 발견하는 즐거움[0m
|
||
[36m</p>[39m
|
||
[36m<h2[39m
|
||
[33mid[39m=[32m"hero-title"[39m
|
||
[36m>[39m
|
||
[0m소음 속 작은 단어,[0m
|
||
[36m<br />[39m
|
||
[36m<em>[39m
|
||
[0m들리시나요?[0m
|
||
[36m</em>[39m
|
||
[36m</h2>[39m
|
||
[36m<p[39m
|
||
[33mclass[39m=[32m"lead"[39m
|
||
[36m>[39m
|
||
[0m도시의 소리 사이에 에스가 숨겨 둔 단어.[0m
|
||
[36m<br />[39m
|
||
[0m조금 더 귀 기울여, 오늘의 행운을 찾아보세요.[0m
|
||
[36m</p>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"hero-meta"[39m
|
||
[36m>[39m
|
||
[36m<span>[39m
|
||
[0m10개의 질문[0m
|
||
[36m</span>[39m
|
||
[36m<span>[39m
|
||
[0m5단계 도전[0m
|
||
[36m</span>[39m
|
||
[36m<span>[39m
|
||
[0m나만의 속도로[0m
|
||
[36m</span>[39m
|
||
[36m</div>[39m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"sound-room"[39m
|
||
[36m>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"room-top"[39m
|
||
[36m>[39m
|
||
[36m<span>[39m
|
||
[36m<i />[39m
|
||
[0m 에스의 사운드 룸[0m
|
||
[36m</span>[39m
|
||
[36m<span>[39m
|
||
[0m8[0m
|
||
[0m가지 환경[0m
|
||
[36m</span>[39m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"orbit orbit-one"[39m
|
||
[36m/>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"orbit orbit-two"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"floating-note note-one"[39m
|
||
[36m>[39m
|
||
[0m♪[0m
|
||
[36m</span>[39m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"floating-note note-two"[39m
|
||
[36m>[39m
|
||
[0m✳[0m
|
||
[36m</span>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"speech-bubble"[39m
|
||
[36m>[39m
|
||
[0m행운의 단어를 찾아보거라![0m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"mascot "[39m
|
||
[36m>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"ear left"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"ear right"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"eye left"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"eye right"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"smile"[39m
|
||
[36m/>[39m
|
||
[36m<b>[39m
|
||
[0mS[0m
|
||
[36m</b>[39m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"room-bottom"[39m
|
||
[36m>[39m
|
||
[36m<div[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"sound-wave "[39m
|
||
[36m>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 10px; animation-delay: 0s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 33.799955450859244px; animation-delay: 0.05s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 16.132986448643948px; animation-delay: 0.1s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 32.219552375865575px; animation-delay: 0.15000000000000002s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 21.858720427326595px; animation-delay: 0.2s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 29.163690702963766px; animation-delay: 0.25s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 26.796992502245015px; animation-delay: 0.30000000000000004s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 24.835290693688798px; animation-delay: 0.35000000000000003s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 30.619883556555898px; animation-delay: 0.4s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 35.39139668035928px; animation-delay: 0.45s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 71.52943948029164px; animation-delay: 0.5s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 19.53593765210872px; animation-delay: 0.55s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 73.98674560913082px; animation-delay: 0.6000000000000001s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 16.952742107140878px; animation-delay: 0.65s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 72.19510052760725px; animation-delay: 0.7000000000000001s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 32.979734657418774px; animation-delay: 0.75s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 66.27347594564635px; animation-delay: 0.8s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 47.480789758355044px; animation-delay: 0.8500000000000001s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 56.615089141222136px; animation-delay: 0.9s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 59.492984932238116px; animation-delay: 0.9500000000000001s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 43.861291911681526px; animation-delay: 1s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 68.21866699734562px; animation-delay: 1.05s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 28.858982496016484px; animation-delay: 1.1s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 73.07841912260051px; animation-delay: 1.1500000000000001s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 12.60436847039135px; animation-delay: 1.2000000000000002s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 73.74953619965402px; animation-delay: 1.25s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 23.8231850360459px; animation-delay: 1.3s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 32.57029511072814px; animation-delay: 1.35s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 20.99981090780238px; animation-delay: 1.4000000000000001s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 29.735764963203792px; animation-delay: 1.4500000000000002s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 26.085500220240995px; animation-delay: 1.5s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 25.59070868046883px; animation-delay: 1.55s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 30.10305417152866px; animation-delay: 1.6s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 20.410372983416416px; animation-delay: 1.6500000000000001s;"[39m
|
||
[36m/>[39m
|
||
[36m<i[39m
|
||
[33mstyle[39m=[32m"height: 32.785692656480826px; animation-delay: 1.7000000000000002s;"[39m
|
||
[36m/>[39m
|
||
[36m</div>[39m
|
||
[36m<span>[39m
|
||
[0m소음 사이, 숨겨진 한마디[0m
|
||
[36m</span>[39m
|
||
[36m</div>[39m
|
||
[36m</div>[39m
|
||
[36m</section>[39m
|
||
[36m<section[39m
|
||
[33maria-labelledby[39m=[32m"setup-title"[39m
|
||
[33mclass[39m=[32m"setup-card"[39m
|
||
[36m>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"setup-heading"[39m
|
||
[36m>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"section-number"[39m
|
||
[36m>[39m
|
||
[0m01[0m
|
||
[36m</span>[39m
|
||
[36m<div>[39m
|
||
[36m<h2[39m
|
||
[33mid[39m=[32m"setup-title"[39m
|
||
[36m>[39m
|
||
[0m나의 소리 탐험 준비[0m
|
||
[36m</h2>[39m
|
||
[36m<p>[39m
|
||
[0m편한 방식으로 시작하세요. 난이도는 조금씩 높아져요.[0m
|
||
[36m</p>[39m
|
||
[36m</div>[39m
|
||
[36m<span[39m
|
||
[33mclass[39m=[32m"round-badge"[39m
|
||
[36m>[39m
|
||
[0m약 3–5분[0m
|
||
[36m</span>[39m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"setup-grid"[39m
|
||
[36m>[39m
|
||
[36m<fieldset[39m
|
||
[33mclass[39m=[32m"mode-field"[39m
|
||
[36m>[39m
|
||
[36m<legend>[39m
|
||
[0m어떻게 답할까요?[0m
|
||
[36m</legend>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"mode-options"[39m
|
||
[36m>[39m
|
||
[36m<label[39m
|
||
[33mclass[39m=[32m"mode-option selected"[39m
|
||
[36m>[39m
|
||
[36m<input[39m
|
||
[33maria-label[39m=[32m"골고루 풀기"[39m
|
||
[33mchecked[39m=[32m""[39m
|
||
[33mname[39m=[32m"mode"[39m
|
||
[33mtype[39m=[32m"radio"[39m
|
||
[33mvalue[39m=[32m"mixed"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"mode-icon"[39m
|
||
[36m>[39m
|
||
[0m◈[0m
|
||
[36m</span>[39m
|
||
[36m<strong>[39m
|
||
[0m골고루 풀기[0m
|
||
[36m</strong>[39m
|
||
[36m<small>[39m
|
||
[0m선택 + 직접 입력[0m
|
||
[36m</small>[39m
|
||
[36m</label>[39m
|
||
[36m<label[39m
|
||
[33mclass[39m=[32m"mode-option "[39m
|
||
[36m>[39m
|
||
[36m<input[39m
|
||
[33maria-label[39m=[32m"보기에서 선택"[39m
|
||
[33mname[39m=[32m"mode"[39m
|
||
[33mtype[39m=[32m"radio"[39m
|
||
[33mvalue[39m=[32m"choice"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"mode-icon"[39m
|
||
[36m>[39m
|
||
[0m☷[0m
|
||
[36m</span>[39m
|
||
[36m<strong>[39m
|
||
[0m보기에서 선택[0m
|
||
[36m</strong>[39m
|
||
[36m<small>[39m
|
||
[0m4개 단어 중 하나[0m
|
||
[36m</small>[39m
|
||
[36m</label>[39m
|
||
[36m<label[39m
|
||
[33mclass[39m=[32m"mode-option "[39m
|
||
[36m>[39m
|
||
[36m<input[39m
|
||
[33maria-label[39m=[32m"직접 입력"[39m
|
||
[33mname[39m=[32m"mode"[39m
|
||
[33mtype[39m=[32m"radio"[39m
|
||
[33mvalue[39m=[32m"text"[39m
|
||
[36m/>[39m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[33mclass[39m=[32m"mode-icon"[39m
|
||
[36m>[39m
|
||
[0m✎[0m
|
||
[36m</span>[39m
|
||
[36m<strong>[39m
|
||
[0m직접 입력[0m
|
||
[36m</strong>[39m
|
||
[36m<small>[39m
|
||
[0m기억한 단어 쓰기[0m
|
||
[36m</small>[39m
|
||
[36m</label>[39m
|
||
[36m</div>[39m
|
||
[36m</fieldset>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"environment-field"[39m
|
||
[36m>[39m
|
||
[36m<label[39m
|
||
[33mfor[39m=[32m"environment"[39m
|
||
[36m>[39m
|
||
[0m배경 환경[0m
|
||
[36m</label>[39m
|
||
[36m<select[39m
|
||
[33mid[39m=[32m"environment"[39m
|
||
[36m>[39m
|
||
[36m<option[39m
|
||
[33mvalue[39m=[32m"rotate"[39m
|
||
[36m>[39m
|
||
[0m여덟 가지 일상 속으로[0m
|
||
[36m</option>[39m
|
||
[36m<option[39m
|
||
[33mvalue[39m=[32m"street"[39m
|
||
[36m>[39m
|
||
[0m길거리[0m
|
||
[36m</option>[39m
|
||
[36m<option[39m
|
||
[33mvalue[39m=[32m"mall"[39m
|
||
[36m>[39m
|
||
[0m쇼핑몰[0m
|
||
[36m</option>[39m
|
||
[36m<option[39m
|
||
[33mvalue[39m=[32m"theater"[39m
|
||
[36m>[39m
|
||
[0m극장[0m
|
||
[36m</option>[39m
|
||
[36m<option[39m
|
||
[33mvalue[39m=[32m"department"[39m
|
||
[36m>[39m
|
||
[0m백화점[0m
|
||
[36m</option>[39m
|
||
[36m<option[39m
|
||
[33mvalue[39m=[32m"market"[39m
|
||
[36m>[39m
|
||
[0m시장[0m
|
||
[36m</option>[39m
|
||
[36m<option[39m
|
||
[33mvalue[39m=[32m"subway"[39m
|
||
[36m>[39m
|
||
[0m지하철[0m
|
||
[36m</option>[39m
|
||
[36m<option[39m
|
||
[33mvalue[39m=[32m"cafe"[39m
|
||
[36m>[39m
|
||
[0m카페[0m
|
||
[36m</option>[39m
|
||
[36m<option[39m
|
||
[33mvalue[39m=[32m"rain"[39m
|
||
[36m>[39m
|
||
[0m비 오는 거리[0m
|
||
[36m</option>[39m
|
||
[36m</select>[39m
|
||
[36m<p>[39m
|
||
[0m길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요.[0m
|
||
[36m</p>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"environment-chips"[39m
|
||
[36m>[39m
|
||
[36m<span>[39m
|
||
[0m길거리[0m
|
||
[36m</span>[39m
|
||
[36m<span>[39m
|
||
[0m카페[0m
|
||
[36m</span>[39m
|
||
[36m<span>[39m
|
||
[0m지하철[0m
|
||
[36m</span>[39m
|
||
[36m<span>[39m
|
||
[0m+5개 환경[0m
|
||
[36m</span>[39m
|
||
[36m</div>[39m
|
||
[36m</div>[39m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"volume-row"[39m
|
||
[36m>[39m
|
||
[36m<label[39m
|
||
[33mfor[39m=[32m"volume"[39m
|
||
[36m>[39m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[36m>[39m
|
||
[0m◖))[0m
|
||
[36m</span>[39m
|
||
[0m 전체 음량[0m
|
||
[36m</label>[39m
|
||
[36m<input[39m
|
||
[33mid[39m=[32m"volume"[39m
|
||
[33mmax[39m=[32m"100"[39m
|
||
[33mmin[39m=[32m"0"[39m
|
||
[33mtype[39m=[32m"range"[39m
|
||
[33mvalue[39m=[32m"35"[39m
|
||
[36m/>[39m
|
||
[36m<output[39m
|
||
[33mfor[39m=[32m"volume"[39m
|
||
[36m>[39m
|
||
[0m35[0m
|
||
[0m%[0m
|
||
[36m</output>[39m
|
||
[36m</div>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"start-row"[39m
|
||
[36m>[39m
|
||
[36m<p>[39m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[36m>[39m
|
||
[0m♬[0m
|
||
[36m</span>[39m
|
||
[0m 편안한 작은 음량으로 시작해 보세요.[0m
|
||
[36m</p>[39m
|
||
[36m<button[39m
|
||
[33mclass[39m=[32m"primary"[39m
|
||
[36m>[39m
|
||
[0m게임 시작 · 소리 듣기 [0m
|
||
[36m<span[39m
|
||
[33maria-hidden[39m=[32m"true"[39m
|
||
[36m>[39m
|
||
[0m→[0m
|
||
[36m</span>[39m
|
||
[36m</button>[39m
|
||
[36m</div>[39m
|
||
[36m</section>[39m
|
||
[36m<section[39m
|
||
[33maria-labelledby[39m=[32m"howto-title"[39m
|
||
[33mclass[39m=[32m"howto"[39m
|
||
[33mid[39m=[32m"howto"[39m
|
||
[36m>[39m
|
||
[36m<h2[39m
|
||
[33mid[39m=[32m"howto-title"[39m
|
||
[36m>[39m
|
||
[0m작은 단어를 발견하는 방법[0m
|
||
[36m</h2>[39m
|
||
[36m<div[39m
|
||
[33mclass[39m=[32m"steps"[39m
|
||
[36m>[39m
|
||
[36m<article>[39m
|
||
[36m<span>[39m
|
||
[0m01[0m
|
||
[36m</span>[39m
|
||
[36m<h3>[39m
|
||
[0m먼저, 귀 기울이기[0m
|
||
[36m</h3>[39m
|
||
[36m<p>[39m
|
||
[0m에스의 안내와 소음 속 문장을 들어요.[0m
|
||
[36m<br />[39m
|
||
[0m처음에는 또렷하게, 점점 더 작게.[0m
|
||
[36m</p>[39m
|
||
[36m</article>[39m
|
||
[36m<article>[39m
|
||
[36m<span>[39m
|
||
[0m02[0m
|
||
[36m</span>[39m
|
||
[36m<h3>[39m
|
||
[0m기억한 단어 찾기[0m
|
||
[36m</h3>[39m
|
||
[36m<p>[39m
|
||
[0m소리가 끝나면 질문이 나타나요.[0m
|
||
[36m<br />[39m
|
||
[0m보기에서 고르거나 직접 입력해요.[0m
|
||
[36m</p>[39m
|
||
[36m</article>[39m
|
||
[36m<article>[39m
|
||
[36m<span>[39m
|
||
[0m03[0m
|
||
[36m</span>[39m
|
||
[36m<h3>[39m
|
||
[0m나의 발견 돌아보기[0m
|
||
[36m</h3>[39m
|
||
[36m<p>[39m
|
||
[0m열 문제 뒤 나의 결과를 확인해요.[0m
|
||
[36m<br />[39m
|
||
[0m놓쳤다면 언제든 다시 들어도 좋아요.[0m
|
||
[36m</p>[39m
|
||
[36m</article>[39m
|
||
[36m</div>[39m
|
||
[36m</section>[39m
|
||
[36m</main>[39m
|
||
[36m<footer[39m
|
||
[33mclass[39m=[32m"footer wrap"[39m
|
||
[36m>[39m
|
||
[36m<p>[39m
|
||
[0mDesigned by OZ at Heat-T Medical Inc.[0m
|
||
[36m</p>[39m
|
||
[36m<p>[39m
|
||
[0m청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다.[0m
|
||
[36m</p>[39m
|
||
[36m<span>[39m
|
||
[0m개인정보 수집 없이, 오직 플레이만.[0m
|
||
[36m</span>[39m
|
||
[36m</footer>[39m
|
||
[36m</div>[39m
|
||
[36m</body>[39m
|
||
❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19
|
||
❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38
|
||
❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17
|
||
❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19
|
||
❯ tests/App.test.tsx:204:19
|
||
202| }),
|
||
203| ).toBeInTheDocument();
|
||
204| expect(screen.getByText("v0.1.1")).toBeInTheDocument();
|
||
| ^
|
||
205| expect(f.audio.play).not.toHaveBeenCalled();
|
||
206| expect(
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: expected '0.1.0' to be '0.1.1' // Object.is equality
|
||
|
||
Expected: "0.1.1"
|
||
Received: "0.1.0"
|
||
|
||
❯ 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.1.1");
|
||
| ^
|
||
10| expect.soft(lock.version).toBe("0.1.1");
|
||
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: expected '0.1.0' to be '0.1.1' // Object.is equality
|
||
|
||
Expected: "0.1.1"
|
||
Received: "0.1.0"
|
||
|
||
❯ tests/neural-release.test.ts:10:29
|
||
8| const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
|
||
9| expect.soft(pkg.version).toBe("0.1.1");
|
||
10| expect.soft(lock.version).toBe("0.1.1");
|
||
| ^
|
||
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
|
||
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: expected '0.1.0' to be '0.1.1' // Object.is equality
|
||
|
||
Expected: "0.1.1"
|
||
Received: "0.1.0"
|
||
|
||
❯ tests/neural-release.test.ts:11:42
|
||
9| expect.soft(pkg.version).toBe("0.1.1");
|
||
10| expect.soft(lock.version).toBe("0.1.1");
|
||
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
|
||
| ^
|
||
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
|
||
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: src/App.tsx: expected 'import { useEffect, useRef, useState …' to contain '0.1.1'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.1.1
|
||
+ import { useEffect, useRef, useState } from "react";
|
||
+ import { AudioEngine, type AudioPort } from "./audio/engine";
|
||
+ import { ENVIRONMENTS, LEVELS } from "./content";
|
||
+ import {
|
||
+ createQuestions,
|
||
+ gameReducer,
|
||
+ initialGame,
|
||
+ normalizeAnswer,
|
||
+ summarize,
|
||
+ type Action,
|
||
+ type GameState,
|
||
+ type Mode,
|
||
+ type Question,
|
||
+ } from "./game";
|
||
+
|
||
+ // 화면은 순수 reducer의 단계만 표시한다. 엔진과 난수 seed를 주입할 수 있어
|
||
+ // 사용자 흐름을 실제 장치 없이도 재현하고 별도로 실제 브라우저에서 검증한다.
|
||
+ export interface AppProps {
|
||
+ audio?: AudioPort;
|
||
+ seed?: number;
|
||
+ }
|
||
+
|
||
+ function SoundWave({ active = false }: { active?: boolean }) {
|
||
+ // 파형은 장식이며 답의 글자 수나 실제 음성 데이터를 노출하지 않는다.
|
||
+ return (
|
||
+ <div className={`sound-wave ${active ? "active" : ""}`} aria-hidden="true">
|
||
+ {Array.from({ length: 35 }, (_, i) => (
|
||
+ <i
|
||
+ key={i}
|
||
+ style={{
|
||
+ height: `${10 + Math.abs(Math.sin(i * 1.7)) * (i > 8 && i < 27 ? 64 : 24)}px`,
|
||
+ animationDelay: `${i * 0.05}s`,
|
||
+ }}
|
||
+ />
|
||
+ ))}
|
||
+ </div>
|
||
+ );
|
||
+ }
|
||
+
|
||
+ function Mascot({ small = false }: { small?: boolean }) {
|
||
+ // CSS로 직접 그린 캐릭터라 외부 이미지·폰트·저작권 불명 자산이 필요 없다.
|
||
+ return (
|
||
+ <div className={`mascot ${small ? "small" : ""}`} aria-hidden="true">
|
||
+ <span className="ear left" />
|
||
+ <span className="ear right" />
|
||
+ <span className="eye left" />
|
||
+ <span className="eye right" />
|
||
+ <span className="smile" />
|
||
+ <b>S</b>
|
||
+ </div>
|
||
+ );
|
||
+ }
|
||
+
|
||
+ // 난이도의 dB는 배경 대비 음성의 상대 크기다. 게임·결과에서 같은 표기를
|
||
+ // 쓰고 부호의 의미를 풀어 설명하되 엔진에 전달하는 수치는 바꾸지 않는다.
|
||
+ function formatSnr(db: number) {
|
||
+ return `SNR ${db > 0 ? "+" : ""}${db} dB`;
|
||
+ }
|
||
+
|
||
+ function describeSnr(db: number) {
|
||
+ if (db === 0) return "음성과 배경의 크기가 같아요.";
|
||
+ return `음성이 배경보다 ${Math.abs(db)} dB ${db > 0 ? "커요" : "작아요"}.`;
|
||
+ }
|
||
+
|
||
+ export default function App({ audio, seed }: AppProps) {
|
||
+ const [engine] = useState<AudioPort>(() => audio ?? new AudioEngine());
|
||
+ const [mode, setMode] = useState<Mode>("mixed");
|
||
+ const [environment, setEnvironment] = useState("rotate");
|
||
+ const [volume, setVolume] = useState(35);
|
||
+ const [game, setGame] = useState<GameState | null>(null);
|
||
+ const [input, setInput] = useState("");
|
||
+ const [attempt, setAttempt] = useState(0);
|
||
+ const token = useRef(0);
|
||
+ const playing = useRef(false);
|
||
+ const introDone = useRef(false);
|
||
+ // 한글 IME의 Enter는 글자 조합 확정에도 사용된다. 조합 중 form 제출은
|
||
+ // 막고 확정 뒤 명시적인 제출만 채점하여 모바일·데스크톱 입력을 보호한다.
|
||
+ const composing = useRef(false);
|
||
+ const focusTarget = useRef<HTMLHeadingElement>(null);
|
||
+ const startButton = useRef<HTMLButtonElement>(null);
|
||
+ 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]);
|
||
+
|
||
+ 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 = () => {
|
||
+ const questions = createQuestions(
|
||
+ (seed ?? Date.now()) + attempt,
|
||
+ mode,
|
||
+ environment,
|
||
+ );
|
||
+ introDone.current = false;
|
||
+ composing.current = false;
|
||
+ setInput("");
|
||
+ setGame(initialGame(questions));
|
||
+ // 사용자 클릭 호출 스택에서 엔진을 열어 자동 재생 정책을 준수한다.
|
||
+ void run(questions[0]!);
|
||
+ };
|
||
+ const home = () => {
|
||
+ token.current++;
|
||
+ playing.current = false;
|
||
+ engine.stop();
|
||
+ composing.current = false;
|
||
+ setGame(null);
|
||
+ setInput("");
|
||
+ setAttempt((a) => a + 1);
|
||
+ requestAnimationFrame(() => startButton.current?.focus());
|
||
+ };
|
||
+ 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";
|
||
+
|
||
+ // 상태와 엔진 연결은 공유하고 각 화면에는 컨트롤 하나만 배치한다.
|
||
+ // 위치가 바뀌어도 기존 접근성 이름·label 연결·사용자가 정한 음량을 유지한다.
|
||
+ const volumeControl = (
|
||
+ <div className="volume-row">
|
||
+ <label htmlFor="volume">
|
||
+ <span aria-hidden="true">◖))</span> 전체 음량
|
||
+ </label>
|
||
+ <input
|
||
+ id="volume"
|
||
+ type="range"
|
||
+ min="0"
|
||
+ max="100"
|
||
+ value={volume}
|
||
+ onChange={(event) => {
|
||
+ const value = Number(event.target.value);
|
||
+ setVolume(value);
|
||
+ engine.setVolume(value / 100);
|
||
+ }}
|
||
+ />
|
||
+ <output htmlFor="volume">{volume}%</output>
|
||
+ </div>
|
||
+ );
|
||
+
|
||
+ return (
|
||
+ <>
|
||
+ <a className="skip-link" href="#main">
|
||
+ 본문으로 건너뛰기
|
||
+ </a>
|
||
+ <header className="header wrap">
|
||
+ <div className="brand">
|
||
+ <span className="brand-mark" aria-hidden="true">
|
||
+ <i />
|
||
+ <i />
|
||
+ <i />
|
||
+ <i />
|
||
+ </span>
|
||
+ <h1>단어를 맞춰라</h1>
|
||
+ <span className="version">v0.1</span>
|
||
+ </div>
|
||
+ <div className="header-actions">
|
||
+ {game ? (
|
||
+ <button className="text-button" onClick={home}>
|
||
+ 처음으로
|
||
+ </button>
|
||
+ ) : (
|
||
+ <a href="#howto">
|
||
+ 플레이 방법 <span aria-hidden="true">↓</span>
|
||
+ </a>
|
||
+ )}
|
||
+ <span className="little-dot" />
|
||
+ 소리에 집중하는 시간
|
||
+ </div>
|
||
+ </header>
|
||
+
|
||
+ <main id="main" className="wrap" tabIndex={-1}>
|
||
+ {!game ? (
|
||
+ <>
|
||
+ <section className="hero" aria-labelledby="hero-title">
|
||
+ <div className="hero-copy">
|
||
+ <p className="eyebrow">
|
||
+ <span /> 귀를 기울이면, 발견하는 즐거움
|
||
+ </p>
|
||
+ <h2 id="hero-title">
|
||
+ 소음 속 작은 단어,
|
||
+ <br />
|
||
+ <em>들리시나요?</em>
|
||
+ </h2>
|
||
+ <p className="lead">
|
||
+ 도시의 소리 사이에 에스가 숨겨 둔 단어.
|
||
+ <br />
|
||
+ 조금 더 귀 기울여, 오늘의 행운을 찾아보세요.
|
||
+ </p>
|
||
+ <div className="hero-meta">
|
||
+ <span>10개의 질문</span>
|
||
+ <span>5단계 도전</span>
|
||
+ <span>나만의 속도로</span>
|
||
+ </div>
|
||
+ </div>
|
||
+ <div className="sound-room">
|
||
+ <div className="room-top">
|
||
+ <span>
|
||
+ <i /> 에스의 사운드 룸
|
||
+ </span>
|
||
+ <span>{ENVIRONMENTS.length}가지 환경</span>
|
||
+ </div>
|
||
+ <div className="orbit orbit-one" />
|
||
+ <div className="orbit orbit-two" />
|
||
+ <span className="floating-note note-one" aria-hidden="true">
|
||
+ ♪
|
||
+ </span>
|
||
+ <span className="floating-note note-two" aria-hidden="true">
|
||
+ ✳
|
||
+ </span>
|
||
+ <div className="speech-bubble">행운의 단어를 찾아보거라!</div>
|
||
+ <Mascot />
|
||
+ <div className="room-bottom">
|
||
+ <SoundWave />
|
||
+ <span>소음 사이, 숨겨진 한마디</span>
|
||
+ </div>
|
||
+ </div>
|
||
+ </section>
|
||
+
|
||
+ <section className="setup-card" aria-labelledby="setup-title">
|
||
+ <div className="setup-heading">
|
||
+ <span className="section-number">01</span>
|
||
+ <div>
|
||
+ <h2 id="setup-title">나의 소리 탐험 준비</h2>
|
||
+ <p>편한 방식으로 시작하세요. 난이도는 조금씩 높아져요.</p>
|
||
+ </div>
|
||
+ <span className="round-badge">약 3–5분</span>
|
||
+ </div>
|
||
+ <div className="setup-grid">
|
||
+ <fieldset className="mode-field">
|
||
+ <legend>어떻게 답할까요?</legend>
|
||
+ <div className="mode-options">
|
||
+ {(
|
||
+ [
|
||
+ {
|
||
+ value: "mixed",
|
||
+ name: "골고루 풀기",
|
||
+ sub: "선택 + 직접 입력",
|
||
+ icon: "◈",
|
||
+ },
|
||
+ {
|
||
+ value: "choice",
|
||
+ name: "보기에서 선택",
|
||
+ sub: "4개 단어 중 하나",
|
||
+ icon: "☷",
|
||
+ },
|
||
+ {
|
||
+ value: "text",
|
||
+ name: "직접 입력",
|
||
+ sub: "기억한 단어 쓰기",
|
||
+ icon: "✎",
|
||
+ },
|
||
+ ] as const
|
||
+ ).map((option) => (
|
||
+ <label
|
||
+ className={`mode-option ${mode === option.value ? "selected" : ""}`}
|
||
+ key={option.value}
|
||
+ >
|
||
+ <input
|
||
+ type="radio"
|
||
+ aria-label={option.name}
|
||
+ name="mode"
|
||
+ value={option.value}
|
||
+ checked={mode === option.value}
|
||
+ onChange={() => setMode(option.value)}
|
||
+ />
|
||
+ <span className="mode-icon" aria-hidden="true">
|
||
+ {option.icon}
|
||
+ </span>
|
||
+ <strong>{option.name}</strong>
|
||
+ <small>{option.sub}</small>
|
||
+ </label>
|
||
+ ))}
|
||
+ </div>
|
||
+ </fieldset>
|
||
+ <div className="environment-field">
|
||
+ <label htmlFor="environment">배경 환경</label>
|
||
+ <select
|
||
+ id="environment"
|
||
+ value={environment}
|
||
+ onChange={(event) => setEnvironment(event.target.value)}
|
||
+ >
|
||
+ <option value="rotate">여덟 가지 일상 속으로</option>
|
||
+ {ENVIRONMENTS.map((e) => (
|
||
+ <option key={e.id} value={e.id}>
|
||
+ {e.name}
|
||
+ </option>
|
||
+ ))}
|
||
+ </select>
|
||
+ <p>
|
||
+ {ENVIRONMENTS.find((e) => e.id === environment)
|
||
+ ?.description ??
|
||
+ "길거리부터 비 오는 거리까지, 매번 다른 공간을 만나요."}
|
||
+ </p>
|
||
+ <div className="environment-chips">
|
||
+ <span>길거리</span>
|
||
+ <span>카페</span>
|
||
+ <span>지하철</span>
|
||
+ <span>+5개 환경</span>
|
||
+ </div>
|
||
+ </div>
|
||
+ </div>
|
||
+ {volumeControl}
|
||
+ <div className="start-row">
|
||
+ <p>
|
||
+ <span aria-hidden="true">♬</span> 편안한 작은 음량으로 시작해
|
||
+ 보세요.
|
||
+ </p>
|
||
+ <button ref={startButton} className="primary" onClick={begin}>
|
||
+ 게임 시작 · 소리 듣기 <span aria-hidden="true">→</span>
|
||
+ </button>
|
||
+ </div>
|
||
+ </section>
|
||
+
|
||
+ <section id="howto" className="howto" aria-labelledby="howto-title">
|
||
+ <h2 id="howto-title">작은 단어를 발견하는 방법</h2>
|
||
+ <div className="steps">
|
||
+ <article>
|
||
+ <span>01</span>
|
||
+ <h3>먼저, 귀 기울이기</h3>
|
||
+ <p>
|
||
+ 에스의 안내와 소음 속 문장을 들어요.
|
||
+ <br />
|
||
+ 처음에는 또렷하게, 점점 더 작게.
|
||
+ </p>
|
||
+ </article>
|
||
+ <article>
|
||
+ <span>02</span>
|
||
+ <h3>기억한 단어 찾기</h3>
|
||
+ <p>
|
||
+ 소리가 끝나면 질문이 나타나요.
|
||
+ <br />
|
||
+ 보기에서 고르거나 직접 입력해요.
|
||
+ </p>
|
||
+ </article>
|
||
+ <article>
|
||
+ <span>03</span>
|
||
+ <h3>나의 발견 돌아보기</h3>
|
||
+ <p>
|
||
+ 열 문제 뒤 나의 결과를 확인해요.
|
||
+ <br />
|
||
+ 놓쳤다면 언제든 다시 들어도 좋아요.
|
||
+ </p>
|
||
+ </article>
|
||
+ </div>
|
||
+ </section>
|
||
+ </>
|
||
+ ) : game.phase === "results" && result ? (
|
||
+ <section className="results-panel panel">
|
||
+ <span className="eyebrow">열 번의 귀 기울임, 하나의 발견</span>
|
||
+ <div className="result-mascot">
|
||
+ <Mascot small />
|
||
+ </div>
|
||
+ <h2 ref={focusTarget} tabIndex={-1}>
|
||
+ 오늘의 소리 탐험을 마쳤어요.
|
||
+ </h2>
|
||
+ <p className="muted">
|
||
+ 작은 소리에 집중한 당신, 다음 탐험에서도 만나요.
|
||
+ </p>
|
||
+ <div className="stats">
|
||
+ <div>
|
||
+ <strong>{result.score.toLocaleString()}</strong>
|
||
+ <span>총 점수</span>
|
||
+ </div>
|
||
+ <div>
|
||
+ <strong>{result.accuracy}%</strong>
|
||
+ <span>정답률 · {result.correct}/10</span>
|
||
+ </div>
|
||
+ <div>
|
||
+ <strong>
|
||
+ {game.bestStreak}
|
||
+ <small>회</small>
|
||
+ </strong>
|
||
+ <span>최고 연속 정답</span>
|
||
+ </div>
|
||
+ </div>
|
||
+ <table aria-label="난이도별 결과">
|
||
+ <thead>
|
||
+ <tr>
|
||
+ <th scope="col">난이도</th>
|
||
+ <th scope="col">SNR</th>
|
||
+ <th scope="col">맞힌 문제</th>
|
||
+ </tr>
|
||
+ </thead>
|
||
+ <tbody>
|
||
+ {result.byLevel.map((row) => (
|
||
+ <tr key={row.level}>
|
||
+ <th scope="row">{row.level}단계</th>
|
||
+ <td>
|
||
+ {formatSnr(LEVELS[row.level - 1]!.db)}
|
||
+ </td>
|
||
+ <td>
|
||
+ {row.correct} / {row.total}
|
||
+ </td>
|
||
+ </tr>
|
||
+ ))}
|
||
+ </tbody>
|
||
+ </table>
|
||
+ <p className="muted">단계 × 100점 + 연속 정답 보너스 (최대 40점)</p>
|
||
+ <button className="primary" onClick={home}>
|
||
+ 다시 시작
|
||
+ </button>
|
||
+ </section>
|
||
+ ) : question && result ? (
|
||
+ <section className="game-layout" aria-label="단어 찾기 게임">
|
||
+ <div className="game-top">
|
||
+ <span className="eyebrow">나의 소리 탐험</span>
|
||
+ <span>
|
||
+ 문제 <b>{String(game.index + 1).padStart(2, "0")}</b> / 10
|
||
+ </span>
|
||
+ </div>
|
||
+ <progress
|
||
+ max={10}
|
||
+ value={game.answers.length}
|
||
+ aria-label="전체 진행률"
|
||
+ />
|
||
+ <div className="game-columns">
|
||
+ <aside className="environment-panel">
|
||
+ <p className="eyebrow">지금 듣고 있는 공간</p>
|
||
+ <h2>{question.environment.name}</h2>
|
||
+ <p>{question.environment.description}</p>
|
||
+ <Mascot small />
|
||
+ <SoundWave active={busy} />
|
||
+ <div className="level-tag">
|
||
+ {question.level}단계 · {formatSnr(question.db)}
|
||
+ </div>
|
||
+ <p className="db-note">
|
||
+ {describeSnr(question.db)}
|
||
+ <br />
|
||
+ {LEVELS[question.level - 1]!.name}
|
||
+ </p>
|
||
+ {volumeControl}
|
||
+ <div className="mini-stats">
|
||
+ <span>
|
||
+ 점수 <b>{result.score}</b>
|
||
+ </span>
|
||
+ <span>
|
||
+ 연속 정답 <b>{game.streak}</b>
|
||
+ </span>
|
||
+ </div>
|
||
+ </aside>
|
||
+ <div className="question-panel panel">
|
||
+ <div className="question-meta">
|
||
+ <span>{question.pattern.title}</span>
|
||
+ <span>
|
||
+ {question.mode === "choice" ? "객관식" : "주관식"}
|
||
+ </span>
|
||
+ </div>
|
||
+ {game.phase === "answer" ? (
|
||
+ <>
|
||
+ <h2 ref={focusTarget} tabIndex={-1}>
|
||
+ {question.pattern.question}
|
||
+ </h2>
|
||
+ <p className="muted">
|
||
+ {question.mode === "choice"
|
||
+ ? "가장 또렷하게 기억나는 단어를 골라 주세요."
|
||
+ : "기억나는 단어 하나를 입력해 주세요."}
|
||
+ </p>
|
||
+ {question.mode === "choice" ? (
|
||
+ <div
|
||
+ role="group"
|
||
+ aria-label="답 선택"
|
||
+ className="answers"
|
||
+ >
|
||
+ {question.options.map((option, i) => (
|
||
+ <button
|
||
+ key={option}
|
||
+ onClick={() =>
|
||
+ dispatch({ type: "ANSWER", input: option })
|
||
+ }
|
||
+ >
|
||
+ <span aria-hidden="true">{i + 1}</span>
|
||
+ {option}
|
||
+ </button>
|
||
+ ))}
|
||
+ </div>
|
||
+ ) : (
|
||
+ <form
|
||
+ onSubmit={(event) => {
|
||
+ event.preventDefault();
|
||
+ if (composing.current) return;
|
||
+ dispatch({ type: "ANSWER", input });
|
||
+ }}
|
||
+ >
|
||
+ <label htmlFor="answer">들은 단어</label>
|
||
+ <input
|
||
+ id="answer"
|
||
+ onCompositionStart={() => {
|
||
+ composing.current = true;
|
||
+ }}
|
||
+ onCompositionEnd={() => {
|
||
+ composing.current = false;
|
||
+ }}
|
||
+ onKeyDown={(event) => {
|
||
+ if (
|
||
+ event.key === "Enter" &&
|
||
+ (event.nativeEvent.isComposing ||
|
||
+ event.nativeEvent.keyCode === 229)
|
||
+ )
|
||
+ event.preventDefault();
|
||
+ }}
|
||
+ autoComplete="off"
|
||
+ maxLength={60}
|
||
+ value={input}
|
||
+ onChange={(event) => setInput(event.target.value)}
|
||
+ placeholder="여기에 단어를 적어 주세요"
|
||
+ />
|
||
+ <button
|
||
+ className="primary"
|
||
+ disabled={!normalizeAnswer(input)}
|
||
+ >
|
||
+ 정답 확인
|
||
+ </button>
|
||
+ </form>
|
||
+ )}
|
||
+ </>
|
||
+ ) : game.phase === "feedback" ? (
|
||
+ <div
|
||
+ className={`feedback ${game.answers.at(-1)!.correct ? "correct" : "incorrect"}`}
|
||
+ >
|
||
+ <span className="feedback-icon" aria-hidden="true">
|
||
+ {game.answers.at(-1)!.correct ? "✓" : "↻"}
|
||
+ </span>
|
||
+ <h2 ref={focusTarget} tabIndex={-1}>
|
||
+ {game.answers.at(-1)!.correct
|
||
+ ? "정답이에요!"
|
||
+ : "다음엔 찾을 수 있어요."}
|
||
+ </h2>
|
||
+ <p>
|
||
+ 숨겨진 단어는 <strong>{question.word.answer}</strong>
|
||
+ </p>
|
||
+ <blockquote>{question.sentence}</blockquote>
|
||
+ <p className="muted">
|
||
+ 내 답: {game.answers.at(-1)!.input} · +
|
||
+ {game.answers.at(-1)!.points}점
|
||
+ </p>
|
||
+ <button className="primary" onClick={next}>
|
||
+ {game.index === 9 ? "결과 보기" : "다음 문제"}{" "}
|
||
+ <span aria-hidden="true">→</span>
|
||
+ </button>
|
||
+ </div>
|
||
+ ) : (
|
||
+ <div className="listening">
|
||
+ <span className="listening-icon" aria-hidden="true">
|
||
+ ♬
|
||
+ </span>
|
||
+ <h2 ref={focusTarget} tabIndex={-1}>
|
||
+ {busy
|
||
+ ? "소리 사이에 집중해 보세요."
|
||
+ : "다음 단어를 만나 볼까요?"}
|
||
+ </h2>
|
||
+ <p>
|
||
+ 문장이 끝나면 질문이 나타나요.
|
||
+ <br />
|
||
+ 작은 단어 하나를 기억해 주세요.
|
||
+ </p>
|
||
+ <p role="status">
|
||
+ {game.phase === "loading"
|
||
+ ? "소리를 준비하고 있어요…"
|
||
+ : game.phase === "playing"
|
||
+ ? "에스의 목소리를 듣고 있어요…"
|
||
+ : "준비되면 문장 듣기를 눌러 주세요."}
|
||
+ </p>
|
||
+ </div>
|
||
+ )}
|
||
+ {game.error && (
|
||
+ <div className="error-box" role="alert">
|
||
+ <p>{game.error}</p>
|
||
+ <button onClick={() => void run(question)}>
|
||
+ 다시 시도
|
||
+ </button>
|
||
+ </div>
|
||
+ )}
|
||
+ {game.phase !== "feedback" && !game.error && (
|
||
+ <div className="play-controls">
|
||
+ <button
|
||
+ className="secondary"
|
||
+ disabled={busy}
|
||
+ onClick={() => void run(question)}
|
||
+ >
|
||
+ {busy
|
||
+ ? "듣는 중…"
|
||
+ : game.phase === "answer"
|
||
+ ? "↻ 다시 듣기"
|
||
+ : "▶ 문장 듣기"}
|
||
+ </button>
|
||
+ {busy && (
|
||
+ <button
|
||
+ className="text-button"
|
||
+ onClick={() => engine.stop()}
|
||
+ >
|
||
+ 듣기 중단
|
||
+ </button>
|
||
+ )}
|
||
+ <small>다시 들어도 점수는 줄어들지 않아요.</small>
|
||
+ </div>
|
||
+ )}
|
||
+ </div>
|
||
+ </div>
|
||
+ </section>
|
||
+ ) : null}
|
||
+ {game?.phase === "results" && volumeControl}
|
||
+ </main>
|
||
+ <footer className="footer wrap">
|
||
+ <p>Designed by OZ at Heat-T Medical Inc.</p>
|
||
+ <p>청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다.</p>
|
||
+ <span>개인정보 수집 없이, 오직 플레이만.</span>
|
||
+ </footer>
|
||
+ </>
|
||
+ );
|
||
+ }
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:13:51
|
||
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
|
||
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
|
||
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
|
||
| ^
|
||
14| }
|
||
15| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: index.html: expected '<!doctype html>\n<!-- 문서 언어를 한국어로 명시하…' to contain '0.1.1'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.1.1
|
||
+ <!doctype html>
|
||
+ <!-- 문서 언어를 한국어로 명시하여 보조 기기의 발음과 줄바꿈 기준을 맞춘다.
|
||
+ 외부 폰트·CDN을 사용하지 않으며 모든 실행 자산은 같은 출처에서 제공한다. -->
|
||
+ <html lang="ko">
|
||
+ <head>
|
||
+ <meta charset="UTF-8" />
|
||
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
+ <meta name="theme-color" content="#f8f9f4" />
|
||
+ <meta
|
||
+ name="description"
|
||
+ content="소음 속 작은 한국어 단어를 찾는 청각적 주의·식별 게임. 에스와 함께 나만의 속도로 소리를 탐험하세요."
|
||
+ />
|
||
+ <title>단어를 맞춰라 · Find-Word v0.1</title>
|
||
+ </head>
|
||
+ <body>
|
||
+ <div id="root"></div>
|
||
+ <script type="module" src="/src/main.tsx"></script>
|
||
+ </body>
|
||
+ </html>
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:13:51
|
||
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
|
||
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
|
||
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
|
||
| ^
|
||
14| }
|
||
15| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' to contain '0.1.1'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.1.1
|
||
+ # 단어를 맞춰라 · Find-Word v0.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 포트를
|
||
+ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다.
|
||
+
|
||
+ ## 음성과 문서
|
||
+
|
||
+ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은
|
||
+ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다.
|
||
+ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
|
||
+ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
|
||
+
|
||
+ - [설계와 상태 전이](docs/ARCHITECTURE.md)
|
||
+ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md)
|
||
+ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md)
|
||
+
|
||
+ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:13:51
|
||
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
|
||
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
|
||
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
|
||
| ^
|
||
14| }
|
||
15| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: LICENSE: expected 'Find-Word v0.1\nPrivate / proprietary…' to contain '0.1.1'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.1.1
|
||
+ Find-Word v0.1
|
||
+ Private / proprietary. All rights reserved.
|
||
+
|
||
+ No license is granted to use, copy, modify, publish, distribute, sublicense,
|
||
+ or sell the original project code, documentation, or assets 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.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:13:51
|
||
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
|
||
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
|
||
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
|
||
| ^
|
||
14| }
|
||
15| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 0.1.1 버전을 package·lock·UI·index·문서에 일치시킨다
|
||
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain '0.1.1'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 0.1.1
|
||
+ # 오디오 출처와 재현
|
||
+
|
||
+ ## 포함 자산
|
||
+
|
||
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
|
||
+ | --- | --- | --- | --- |
|
||
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
|
||
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
|
||
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
|
||
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
|
||
+
|
||
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
|
||
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
|
||
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
|
||
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
|
||
+
|
||
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
|
||
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
|
||
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
|
||
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
|
||
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
|
||
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
|
||
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
|
||
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
|
||
+
|
||
+ ## 사용 도구와 라이선스
|
||
+
|
||
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
|
||
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
|
||
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
|
||
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
|
||
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
|
||
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
|
||
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
|
||
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
|
||
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
|
||
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
|
||
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
|
||
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
|
||
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
|
||
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
|
||
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
|
||
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
|
||
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
|
||
+
|
||
+ ## 생성 절차
|
||
+
|
||
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
|
||
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
|
||
+
|
||
+ ```bash
|
||
+ npm run audio:generate
|
||
+ npm test -- tests/assets.test.ts
|
||
+ npm run build
|
||
+ ```
|
||
+
|
||
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
|
||
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
|
||
+
|
||
+ ```bash
|
||
+ mkdir -p .cache/espeak
|
||
+ cd .cache/espeak
|
||
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
|
||
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
|
||
+ cd ../..
|
||
+ npm run audio:generate
|
||
+ ```
|
||
+
|
||
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
|
||
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
|
||
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
|
||
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
|
||
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
|
||
+
|
||
+ ## 음량·품질 한계
|
||
+
|
||
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
|
||
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
|
||
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
|
||
+
|
||
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
|
||
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
|
||
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:13:51
|
||
11| expect.soft(lock.packages[""].version).toBe("0.1.1");
|
||
12| for (const path of ["src/App.tsx", "index.html", "README.md", "LICEN…
|
||
13| expect.soft(readFileSync(path, "utf8"), path).toContain("0.1.1");
|
||
| ^
|
||
14| }
|
||
15| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[9/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: expected 'python3 scripts/generate_audio.py' to be '.venv-audio/bin/python scripts/genera…' // Object.is equality
|
||
|
||
Expected: ".venv-audio/bin/python scripts/generate_audio.py"
|
||
Received: "python3 scripts/generate_audio.py"
|
||
|
||
❯ tests/neural-release.test.ts:19:46
|
||
17| it("고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다", () => {
|
||
18| const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||
19| expect.soft(pkg.scripts["audio:generate"]).toBe(".venv-audio/bin/pyt…
|
||
| ^
|
||
20| for (const path of ["README.md", "docs/AUDIO_PROVENANCE.md"]) {
|
||
21| const doc = readFileSync(path, "utf8");
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[10/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' to contain 'requirements-audio.txt'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- requirements-audio.txt
|
||
+ # 단어를 맞춰라 · Find-Word v0.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 포트를
|
||
+ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다.
|
||
+
|
||
+ ## 음성과 문서
|
||
+
|
||
+ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은
|
||
+ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다.
|
||
+ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
|
||
+ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
|
||
+
|
||
+ - [설계와 상태 전이](docs/ARCHITECTURE.md)
|
||
+ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md)
|
||
+ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md)
|
||
+
|
||
+ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:22:28
|
||
20| for (const path of ["README.md", "docs/AUDIO_PROVENANCE.md"]) {
|
||
21| const doc = readFileSync(path, "utf8");
|
||
22| expect.soft(doc, path).toContain("requirements-audio.txt");
|
||
| ^
|
||
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
|
||
24| expect.soft(doc, path).toContain("7.3.1");
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[11/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' to contain 'ko-KR-HyunsuMultilingualNeural'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- ko-KR-HyunsuMultilingualNeural
|
||
+ # 단어를 맞춰라 · Find-Word v0.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 포트를
|
||
+ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다.
|
||
+
|
||
+ ## 음성과 문서
|
||
+
|
||
+ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은
|
||
+ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다.
|
||
+ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
|
||
+ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
|
||
+
|
||
+ - [설계와 상태 전이](docs/ARCHITECTURE.md)
|
||
+ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md)
|
||
+ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md)
|
||
+
|
||
+ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:23:28
|
||
21| const doc = readFileSync(path, "utf8");
|
||
22| expect.soft(doc, path).toContain("requirements-audio.txt");
|
||
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
|
||
| ^
|
||
24| expect.soft(doc, path).toContain("7.3.1");
|
||
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[12/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' to contain '7.3.1'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 7.3.1
|
||
+ # 단어를 맞춰라 · Find-Word v0.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 포트를
|
||
+ 테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다.
|
||
+
|
||
+ ## 음성과 문서
|
||
+
|
||
+ 한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은
|
||
+ 브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다.
|
||
+ 생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
|
||
+ 참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
|
||
+
|
||
+ - [설계와 상태 전이](docs/ARCHITECTURE.md)
|
||
+ - [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md)
|
||
+ - [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md)
|
||
+
|
||
+ 이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:24:28
|
||
22| expect.soft(doc, path).toContain("requirements-audio.txt");
|
||
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
|
||
24| expect.soft(doc, path).toContain("7.3.1");
|
||
| ^
|
||
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
|
||
26| }
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[13/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: README.md: expected '# 단어를 맞춰라 · Find-Word v0.1\n\n소음 속에 숨…' not to match /espeak|포먼트/i
|
||
|
||
- Expected:
|
||
/espeak|포먼트/i
|
||
|
||
+ Received:
|
||
"# 단어를 맞춰라 · Find-Word v0.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 포트를
|
||
테스트 중에만 열고 종료합니다. 스크린샷은 `.agent/evidence/`에 남습니다.
|
||
|
||
## 음성과 문서
|
||
|
||
한국어 WAV는 직접 쓴 문장을 로컬 eSpeak NG로 합성했습니다. 환경음은
|
||
브라우저에서 고정 seed로 합성합니다. 외부 음원이나 신경망 음성 모델을 사용하지 않습니다.
|
||
생성 방법, 도구 라이선스, 합성 음질의 한계는 [AUDIO_PROVENANCE.md](docs/AUDIO_PROVENANCE.md)를
|
||
참고하세요. 실제 음량은 출력 장치에 따라 달라지며 표기 dB는 디지털 상대값입니다.
|
||
|
||
- [설계와 상태 전이](docs/ARCHITECTURE.md)
|
||
- [Neo를 위한 정적 배포 안내](docs/DEPLOYMENT.md)
|
||
- [RED/GREEN 및 실제 검증 결과](.agent/RESULT.md)
|
||
|
||
이번 작업에서는 서버 배포와 Git commit/push를 실행하지 않습니다.
|
||
"
|
||
|
||
❯ tests/neural-release.test.ts:25:32
|
||
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
|
||
24| expect.soft(doc, path).toContain("7.3.1");
|
||
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
|
||
| ^
|
||
26| }
|
||
27| const provenance = readFileSync("docs/AUDIO_PROVENANCE.md", "utf8");
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[14/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain 'requirements-audio.txt'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- requirements-audio.txt
|
||
+ # 오디오 출처와 재현
|
||
+
|
||
+ ## 포함 자산
|
||
+
|
||
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
|
||
+ | --- | --- | --- | --- |
|
||
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
|
||
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
|
||
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
|
||
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
|
||
+
|
||
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
|
||
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
|
||
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
|
||
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
|
||
+
|
||
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
|
||
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
|
||
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
|
||
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
|
||
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
|
||
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
|
||
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
|
||
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
|
||
+
|
||
+ ## 사용 도구와 라이선스
|
||
+
|
||
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
|
||
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
|
||
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
|
||
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
|
||
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
|
||
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
|
||
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
|
||
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
|
||
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
|
||
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
|
||
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
|
||
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
|
||
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
|
||
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
|
||
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
|
||
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
|
||
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
|
||
+
|
||
+ ## 생성 절차
|
||
+
|
||
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
|
||
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
|
||
+
|
||
+ ```bash
|
||
+ npm run audio:generate
|
||
+ npm test -- tests/assets.test.ts
|
||
+ npm run build
|
||
+ ```
|
||
+
|
||
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
|
||
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
|
||
+
|
||
+ ```bash
|
||
+ mkdir -p .cache/espeak
|
||
+ cd .cache/espeak
|
||
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
|
||
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
|
||
+ cd ../..
|
||
+ npm run audio:generate
|
||
+ ```
|
||
+
|
||
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
|
||
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
|
||
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
|
||
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
|
||
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
|
||
+
|
||
+ ## 음량·품질 한계
|
||
+
|
||
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
|
||
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
|
||
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
|
||
+
|
||
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
|
||
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
|
||
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:22:28
|
||
20| for (const path of ["README.md", "docs/AUDIO_PROVENANCE.md"]) {
|
||
21| const doc = readFileSync(path, "utf8");
|
||
22| expect.soft(doc, path).toContain("requirements-audio.txt");
|
||
| ^
|
||
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
|
||
24| expect.soft(doc, path).toContain("7.3.1");
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[15/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain 'ko-KR-HyunsuMultilingualNeural'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- ko-KR-HyunsuMultilingualNeural
|
||
+ # 오디오 출처와 재현
|
||
+
|
||
+ ## 포함 자산
|
||
+
|
||
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
|
||
+ | --- | --- | --- | --- |
|
||
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
|
||
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
|
||
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
|
||
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
|
||
+
|
||
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
|
||
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
|
||
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
|
||
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
|
||
+
|
||
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
|
||
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
|
||
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
|
||
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
|
||
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
|
||
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
|
||
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
|
||
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
|
||
+
|
||
+ ## 사용 도구와 라이선스
|
||
+
|
||
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
|
||
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
|
||
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
|
||
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
|
||
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
|
||
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
|
||
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
|
||
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
|
||
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
|
||
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
|
||
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
|
||
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
|
||
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
|
||
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
|
||
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
|
||
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
|
||
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
|
||
+
|
||
+ ## 생성 절차
|
||
+
|
||
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
|
||
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
|
||
+
|
||
+ ```bash
|
||
+ npm run audio:generate
|
||
+ npm test -- tests/assets.test.ts
|
||
+ npm run build
|
||
+ ```
|
||
+
|
||
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
|
||
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
|
||
+
|
||
+ ```bash
|
||
+ mkdir -p .cache/espeak
|
||
+ cd .cache/espeak
|
||
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
|
||
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
|
||
+ cd ../..
|
||
+ npm run audio:generate
|
||
+ ```
|
||
+
|
||
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
|
||
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
|
||
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
|
||
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
|
||
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
|
||
+
|
||
+ ## 음량·품질 한계
|
||
+
|
||
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
|
||
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
|
||
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
|
||
+
|
||
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
|
||
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
|
||
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:23:28
|
||
21| const doc = readFileSync(path, "utf8");
|
||
22| expect.soft(doc, path).toContain("requirements-audio.txt");
|
||
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
|
||
| ^
|
||
24| expect.soft(doc, path).toContain("7.3.1");
|
||
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[16/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain '7.3.1'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 7.3.1
|
||
+ # 오디오 출처와 재현
|
||
+
|
||
+ ## 포함 자산
|
||
+
|
||
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
|
||
+ | --- | --- | --- | --- |
|
||
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
|
||
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
|
||
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
|
||
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
|
||
+
|
||
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
|
||
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
|
||
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
|
||
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
|
||
+
|
||
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
|
||
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
|
||
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
|
||
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
|
||
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
|
||
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
|
||
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
|
||
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
|
||
+
|
||
+ ## 사용 도구와 라이선스
|
||
+
|
||
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
|
||
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
|
||
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
|
||
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
|
||
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
|
||
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
|
||
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
|
||
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
|
||
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
|
||
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
|
||
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
|
||
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
|
||
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
|
||
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
|
||
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
|
||
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
|
||
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
|
||
+
|
||
+ ## 생성 절차
|
||
+
|
||
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
|
||
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
|
||
+
|
||
+ ```bash
|
||
+ npm run audio:generate
|
||
+ npm test -- tests/assets.test.ts
|
||
+ npm run build
|
||
+ ```
|
||
+
|
||
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
|
||
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
|
||
+
|
||
+ ```bash
|
||
+ mkdir -p .cache/espeak
|
||
+ cd .cache/espeak
|
||
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
|
||
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
|
||
+ cd ../..
|
||
+ npm run audio:generate
|
||
+ ```
|
||
+
|
||
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
|
||
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
|
||
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
|
||
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
|
||
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
|
||
+
|
||
+ ## 음량·품질 한계
|
||
+
|
||
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
|
||
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
|
||
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
|
||
+
|
||
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
|
||
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
|
||
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:24:28
|
||
22| expect.soft(doc, path).toContain("requirements-audio.txt");
|
||
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
|
||
24| expect.soft(doc, path).toContain("7.3.1");
|
||
| ^
|
||
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
|
||
26| }
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[17/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: docs/AUDIO_PROVENANCE.md: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' not to match /espeak|포먼트/i
|
||
|
||
- Expected:
|
||
/espeak|포먼트/i
|
||
|
||
+ Received:
|
||
"# 오디오 출처와 재현
|
||
|
||
## 포함 자산
|
||
|
||
| 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
|
||
| --- | --- | --- | --- |
|
||
| `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
|
||
| 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
|
||
| 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
|
||
| 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
|
||
|
||
모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
|
||
샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
|
||
‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
|
||
다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
|
||
|
||
WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
|
||
**20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
|
||
`public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
|
||
FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
|
||
포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
|
||
빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
|
||
청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
|
||
단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
|
||
|
||
## 사용 도구와 라이선스
|
||
|
||
- [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
|
||
이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
|
||
[공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
|
||
- [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
|
||
LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
|
||
- 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
|
||
[GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
|
||
[GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
|
||
출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
|
||
출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
|
||
프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
|
||
- 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
|
||
사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
|
||
프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
|
||
[루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
|
||
scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
|
||
있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
|
||
|
||
## 생성 절차
|
||
|
||
일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
|
||
필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
|
||
|
||
```bash
|
||
npm run audio:generate
|
||
npm test -- tests/assets.test.ts
|
||
npm run build
|
||
```
|
||
|
||
현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
|
||
이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
|
||
|
||
```bash
|
||
mkdir -p .cache/espeak
|
||
cd .cache/espeak
|
||
apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
|
||
for pkg in *.deb; do dpkg-deb -x \"$pkg\" root; done
|
||
cd ../..
|
||
npm run audio:generate
|
||
```
|
||
|
||
`scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
|
||
자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
|
||
PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
|
||
Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
|
||
해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
|
||
|
||
## 음량·품질 한계
|
||
|
||
보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
|
||
안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
|
||
물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
|
||
|
||
포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
|
||
장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
|
||
검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
|
||
"
|
||
|
||
❯ tests/neural-release.test.ts:25:32
|
||
23| expect.soft(doc, path).toContain("ko-KR-HyunsuMultilingualNeural");
|
||
24| expect.soft(doc, path).toContain("7.3.1");
|
||
25| expect.soft(doc, path).not.toMatch(/espeak|포먼트/i);
|
||
| ^
|
||
26| }
|
||
27| const provenance = readFileSync("docs/AUDIO_PROVENANCE.md", "utf8");
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[18/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain 'https://www.microsoft.com/en-us/servi…'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- https://www.microsoft.com/en-us/servicesagreement
|
||
+ # 오디오 출처와 재현
|
||
+
|
||
+ ## 포함 자산
|
||
+
|
||
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
|
||
+ | --- | --- | --- | --- |
|
||
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
|
||
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
|
||
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
|
||
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
|
||
+
|
||
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
|
||
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
|
||
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
|
||
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
|
||
+
|
||
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
|
||
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
|
||
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
|
||
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
|
||
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
|
||
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
|
||
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
|
||
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
|
||
+
|
||
+ ## 사용 도구와 라이선스
|
||
+
|
||
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
|
||
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
|
||
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
|
||
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
|
||
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
|
||
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
|
||
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
|
||
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
|
||
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
|
||
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
|
||
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
|
||
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
|
||
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
|
||
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
|
||
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
|
||
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
|
||
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
|
||
+
|
||
+ ## 생성 절차
|
||
+
|
||
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
|
||
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
|
||
+
|
||
+ ```bash
|
||
+ npm run audio:generate
|
||
+ npm test -- tests/assets.test.ts
|
||
+ npm run build
|
||
+ ```
|
||
+
|
||
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
|
||
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
|
||
+
|
||
+ ```bash
|
||
+ mkdir -p .cache/espeak
|
||
+ cd .cache/espeak
|
||
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
|
||
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
|
||
+ cd ../..
|
||
+ npm run audio:generate
|
||
+ ```
|
||
+
|
||
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
|
||
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
|
||
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
|
||
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
|
||
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
|
||
+
|
||
+ ## 음량·품질 한계
|
||
+
|
||
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
|
||
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
|
||
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
|
||
+
|
||
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
|
||
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
|
||
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:28:27
|
||
26| }
|
||
27| const provenance = readFileSync("docs/AUDIO_PROVENANCE.md", "utf8");
|
||
28| expect.soft(provenance).toContain("https://www.microsoft.com/en-us/s…
|
||
| ^
|
||
29| expect.soft(provenance).toContain("LGPL");
|
||
30| expect.soft(provenance).toContain("독점 권리를 주장하지 않는다");
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[19/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: expected '# 오디오 출처와 재현\n\n## 포함 자산\n\n| 대상 | 생성…' to contain '독점 권리를 주장하지 않는다'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- 독점 권리를 주장하지 않는다
|
||
+ # 오디오 출처와 재현
|
||
+
|
||
+ ## 포함 자산
|
||
+
|
||
+ | 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
|
||
+ | --- | --- | --- | --- |
|
||
+ | `public/audio/intro.wav` | 로컬 eSpeak NG 한국어 포먼트 합성 | `src/content.ts`의 INTRO | PCM WAV |
|
||
+ | 목표 음성 100개 | 같은 로컬 엔진 | 10 패턴 × 10 단어 | PCM WAV |
|
||
+ | 길거리·쇼핑몰·극장·백화점·시장·지하철·카페·비 오는 거리 | 직접 작성한 결정론적 DSP | `ENVIRONMENTS`, 고정 seed | 브라우저 메모리에서 합성 |
|
||
+ | 화면 파형·S 캐릭터 | 직접 작성한 CSS | `App.tsx`, `styles.css` | CSS, 외부 이미지 없음 |
|
||
+
|
||
+ 모든 한국어 문장은 이번 앱을 위해 직접 작성했다. 다른 사람의 녹음, 노래,
|
||
+ 샘플 라이브러리, 음성 복제, 신경망 모델을 포함하지 않는다. 웃음도 INTRO의
|
||
+ ‘으하하하~~’ 문자열을 동일 엔진으로 합성한 것이다. 효과를 위해 타인 음원을
|
||
+ 다운로드하지 않았다. 런타임에 speechSynthesis 또는 네트워크 TTS도 호출하지 않는다.
|
||
+
|
||
+ WAV는 16kHz, mono, signed 16-bit little-endian PCM이다. 101개 합계는
|
||
+ **20,497,222바이트**다. 파일별 원문·바이트 수·SHA-256은
|
||
+ `public/audio/manifest.json`에 있다. 생성 시 실행 도구에서 읽은 eSpeak NG 1.51,
|
||
+ FFmpeg 6.1.1-3ubuntu5의 실제 버전도 기록했다. `tests/assets.test.ts`는 원문 대응, PCM
|
||
+ 포맷, 비무음 신호, 20MiB 이내 크기, gain 적용 후 피크 여유를 검사한다.
|
||
+ 빌드 마지막에는 `dist/audio` 파일의 해시도 확인한다.
|
||
+ 청취 연산 변경에 맞춰 101개 WAV와 manifest를 모두 재생성했다. 정답과 경쟁
|
||
+ 단어는 `renderSentence`로 채우며, 순서·반복·정정·조건·방향 단서가 실제 음성에 들어간다.
|
||
+
|
||
+ ## 사용 도구와 라이선스
|
||
+
|
||
+ - [eSpeak NG 공식 저장소](https://github.com/espeak-ng/espeak-ng): GPL-3.0-or-later.
|
||
+ 이번에는 Ubuntu 24.04 패키지 `1.51+dfsg-12build1`, `ko` 포먼트 음성을 사용했다.
|
||
+ [공식 언어 목록](https://github.com/espeak-ng/espeak-ng/blob/master/docs/languages.md)에 한국어가 있다.
|
||
+ - [FFmpeg 공식 라이선스 안내](https://ffmpeg.org/legal.html): 빌드 구성에 따라
|
||
+ LGPL/GPL. 생성 과정에서 PCM 리샘플링만 사용했으며 실행 파일을 배포하지 않는다.
|
||
+ - 합성 엔진의 프로그램 라이선스와 그 출력물은 구분한다.
|
||
+ [GPLv3 §2](https://www.gnu.org/licenses/gpl-3.0.html#section2) 및
|
||
+ [GNU 출력물 설명](https://www.gnu.org/licenses/gpl-faq.html#GPLOutput)에 따르면
|
||
+ 출력이 그 프로그램을 바탕으로 한 저작물인 경우에만 프로그램 라이선스가
|
||
+ 출력에 적용된다. 이 작업의 산출물은 직접 작성한 텍스트의 포먼트 합성 파형이며,
|
||
+ 프로그램 코드·외부 녹음·모델을 포함하지 않는 자체 생성 오디오로 취급한다.
|
||
+ - 엔진·라이브러리·다운로드한 Debian 패키지는 `.cache/`에서만 생성 도구로
|
||
+ 사용하며 Git 대상과 `dist/`에 들어가지 않는다. 별도 외부 음원 라이선스는 없다.
|
||
+ 프로젝트 자체의 소스·문서·자체 생성 자산은 private / all-rights-reserved다.
|
||
+ [루트 LICENSE](../LICENSE)를 따른다. 별도로 번들에 포함되는 React, ReactDOM,
|
||
+ scheduler의 MIT 전문은 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)에
|
||
+ 있으며 `dist/THIRD_PARTY_NOTICES.md`로 함께 배포한다.
|
||
+
|
||
+ ## 생성 절차
|
||
+
|
||
+ 일반적인 Ubuntu 개발 환경에는 `espeak-ng`, `ffmpeg`, Python 3, Node.js가
|
||
+ 필요하다. **이미 포함된 WAV로 빌드·실행할 때는 이 도구가 필요 없다.**
|
||
+
|
||
+ ```bash
|
||
+ npm run audio:generate
|
||
+ npm test -- tests/assets.test.ts
|
||
+ npm run build
|
||
+ ```
|
||
+
|
||
+ 현재 작업에서는 시스템 설치 없이 다음 명령으로 worktree 내부에 도구를 풀었다.
|
||
+ 이 명령은 문서화용이며 서버에서 실행한 이력이 없다.
|
||
+
|
||
+ ```bash
|
||
+ mkdir -p .cache/espeak
|
||
+ cd .cache/espeak
|
||
+ apt download espeak-ng espeak-ng-data libespeak-ng1 libpcaudio0 libsonic0
|
||
+ for pkg in *.deb; do dpkg-deb -x "$pkg" root; done
|
||
+ cd ../..
|
||
+ npm run audio:generate
|
||
+ ```
|
||
+
|
||
+ `scripts/generate_audio.py`가 시스템 엔진 또는 `.cache/espeak/root/usr`를
|
||
+ 자동 선택한다. `ko`, 속도 145, 피치 45, 진폭 100을 고정하고 FFmpeg로 16kHz
|
||
+ PCM을 만든 뒤 Python wave 모듈로 메타데이터 없는 WAV를 쓴다. 런타임 데이터는
|
||
+ Node의 type stripping으로 읽어 텍스트 중복을 피한다. 같은 엔진·버전·플랫폼에서는
|
||
+ 해시로 재현 여부를 비교할 수 있다. 다른 버전은 새 출처와 해시를 검토해야 한다.
|
||
+
|
||
+ ## 음량·품질 한계
|
||
+
|
||
+ 보이스와 배경은 같은 디지털 RMS로 정규화되고 음성 경로만 dB gain을 적용한다.
|
||
+ 안내의 gain은 1.8에서 0.12로 감소한다. 기본 전체 음량은 35%이다.
|
||
+ 물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
|
||
+
|
||
+ 포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
|
||
+ 장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
|
||
+ 검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:30:27
|
||
28| expect.soft(provenance).toContain("https://www.microsoft.com/en-us/s…
|
||
29| expect.soft(provenance).toContain("LGPL");
|
||
30| expect.soft(provenance).toContain("독점 권리를 주장하지 않는다");
|
||
| ^
|
||
31| expect.soft(readFileSync("LICENSE", "utf8")).toContain("does not cla…
|
||
32| });
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[20/21]⎯
|
||
|
||
FAIL tests/neural-release.test.ts > 고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를 문서화한다
|
||
AssertionError: expected 'Find-Word v0.1\nPrivate / proprietary…' to contain 'does not claim exclusive rights to ge…'
|
||
|
||
- Expected
|
||
+ Received
|
||
|
||
- does not claim exclusive rights to generated speech
|
||
+ Find-Word v0.1
|
||
+ Private / proprietary. All rights reserved.
|
||
+
|
||
+ No license is granted to use, copy, modify, publish, distribute, sublicense,
|
||
+ or sell the original project code, documentation, or assets 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.
|
||
+
|
||
|
||
❯ tests/neural-release.test.ts:31:48
|
||
29| expect.soft(provenance).toContain("LGPL");
|
||
30| expect.soft(provenance).toContain("독점 권리를 주장하지 않는다");
|
||
31| expect.soft(readFileSync("LICENSE", "utf8")).toContain("does not cla…
|
||
| ^
|
||
32| });
|
||
33|
|
||
|
||
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[21/21]⎯
|
||
|
||
|
||
Test Files 2 failed (2)
|
||
Tests 3 failed | 12 skipped (15)
|
||
Start at 06:47:57
|
||
Duration 1.28s (environment 48%, tests 22%, setup 17%, transform 10%, import 3%, worker 1%)
|