feat: release Find-Word v0.1

This commit is contained in:
oz
2026-10-14 06:19:24 +00:00
parent 28ee2ce498
commit 1c0069c367
258 changed files with 21509 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
# 설계
## 데이터와 출제
`src/content.ts`는 패턴, 질문, 단어, 오답 규칙, 배경 프리셋, 상대 dB를 서로 다른
배열로 보관한다. `renderSentence`가 각 패턴의 `{word}`를 정답으로,
`{distractor1}`·`{distractor2}`를 해당 보기의 오답으로 채운다. 같은 함수를
음성 생성기도 사용하며 런타임 문장과 WAV의 대응은 manifest 테스트가 검증한다.
`Pattern.mechanic`은 다음 10개 고유 청취 연산을 나타낸다. 인용 단어를 활용형으로
바꾸지 않으므로 동사·부사 표제어가 오답으로 나와도 문장이 자연스럽다.
| mechanic | 청취 과제 |
| --- | --- |
| `direct-target` | 한 개의 목표 단어를 직접 식별 |
| `distractor-password` | 연습 암호를 제외하고 진짜 암호 선택 |
| `ignore-then-remember` | 앞 단어를 잊고 이후에 지시된 단어 기억 |
| `first` | 세 단어 중 처음 선택 |
| `last` | 세 단어 중 마지막 선택 |
| `middle` | 세 단어 중 가운데 선택 |
| `repetition` | 오답 사이에서 두 번 등장한 단어 찾기 |
| `correction` | 처음 답을 취소하고 정정된 답 선택 |
| `conditional-cue` | 초록/빨강 규칙과 현재 신호를 함께 적용 |
| `spatial-right-cue` | 좌우 표지 중 오른쪽에 해당하는 단어 선택 |
방향 단서는 문장의 의미이며 stereo 위치 재현 과제는 아니다. 모든 음성은 mono다.
직접 식별을 제외한 문장은 실제 보기의 경쟁 단어도 발화하므로, 들린 단어 하나를
무조건 고르는 것으로 해결되지 않는다. 테스트는 100개 문장 전체에서 단어 순서,
목표 반복 횟수, 취소·조건·방향 단서와 질문 의도를 검증한다.
`src/game.ts`의 `createQuestions`는 seed 기반 PRNG와 Fisher–Yates로
유형·단어·정답 위치를 섞는다. 한 세션에서 유형과 정답 단어는 각각 한 번씩
나온다. 100개 문장 조합에 응답 방식·환경·난이도를 독립적으로 결합한다.
오답의 `rule`은 `RULE_DIFFICULTY`에 연결된다. 음절 교체 1, 초성 교체 2,
모음 교체 3, 받침 교체 4는 편집상 예상 혼동 순서이며 검증된 청각 척도가
아니다. 모든 문항에 서로 다른 세 변형을 포함한다. 음량 단계는 이 값과 분리된다.
## 게임 상태
```mermaid
stateDiagram-v2
[*] --> ready
ready --> loading: PLAY
loading --> playing: STARTED
loading --> ready: ERROR
playing --> ready: ERROR / 중단
playing --> answer: ENDED
answer --> loading: 다시 듣기
answer --> feedback: ANSWER
feedback --> ready: NEXT (다음 문제)
feedback --> results: NEXT (마지막 문제)
```
허용되지 않은 이벤트는 동일 상태를 반환한다. 재생 전/중에는 채점할 수 없고,
피드백 이후의 중복 답변·다음 클릭도 한 번만 반영된다. UI에서도 버튼 비활성화와
동기 ref 잠금을 함께 사용한다. 세션 토큰으로 처음 화면 복귀·unmount 뒤 완료된
Promise를 무시한다. 질문과 보기는 재생 완료 후 DOM에 생성되며 정답 문장은
채점 후에만 표시된다.
주관식은 NFC 한글 정규화 후 공백과 유니코드 문장부호만 제거한다. 비어 있는
답변과 부분 일치는 정답으로 인정하지 않는다. React 텍스트 노드로만 출력하여
입력을 HTML로 해석하지 않는다.
점수는 정답 시 `100 × 단계 + 10 × min(연속 정답 - 1, 4)`이다. 오답은 0점,
현재 연속 정답을 초기화한다. 최고 연속 정답과 난이도별 정답 수는 유지한다.
## 오디오
`src/audio/dsp.ts`는 순수 수치 함수이며 장치가 없어도 테스트할 수 있다.
정수 seed 잡음에 저역 통과 필터, 사인파, 진폭 변조를 조합하고 양 끝을 페이드한다.
8개 프리셋은 실제 현장 녹음이 아니라 환경을 추상적으로 표현한 합성 신호다.
`src/audio/engine.ts`는 클릭 이벤트에서 AudioContext 생성·resume을 호출한 뒤
같은 출처의 WAV를 로드한다. mono PCM을 decode하고 음성과 배경의 전체 버퍼
RMS를 0.03으로 정규화한다. `gain = 10^(dB/20)`으로 목표 gain을 지정한다.
시간별·주파수별 청감이나 물리적 dB SPL을 보장하는 수치는 아니다.
```text
배경 BufferSource → 배경 Gain (1, 시작/끝 fade) ─┐
목표 BufferSource → 음성 Gain (10^(dB/20)) ─────┼→ master Gain → destination
안내 BufferSource → 안내 Gain (1.8 → 0.12) ─────┘
```
첫 문제에서는 웃음과 안내 음성을 낮추어 배경에 묻히게 한 후 목표 문장을
재생한다. 이후에는 목표만 재생한다. 안내 도중 실패했으면 재시도 시 안내도
다시 재생한다. 기본 master는 35%이며 음량 조절은 두 신호의 비율을 유지한다.
동시 play는 거부한다. 정지·실패·완료는 onended 해제, 소스 stop/disconnect,
AbortController 취소, 타이머 제거를 수행한다. load/decode 뒤에도 작업 토큰을
검사하여 취소된 이전 작업이 소리를 시작하지 못하게 한다. 준비 15초,
재생 길이 + 5초의 제한으로 무한 대기를 피한다. unmount는 context까지 닫는다.
## 화면·접근성·보안
`App.tsx`는 시작·플레이·결과 화면, 음량, 모드, 배경 선택을 담당한다.
정답/피드백/결과 제목으로 초점을 이동하고 결과표에 caption에 해당하는
접근성 이름과 scope를 둔다. 네이티브 radio/select/button/form으로 방향키,
Tab/Enter와 터치 조작을 지원한다. 장식 파형·캐릭터는 aria-hidden이며
reduced motion에서는 파형이 움직이지 않는다. 오류는 role=alert로 전달한다.
외부 폰트, CDN, 이미지, 분석 도구, 로그인, 쿠키, localStorage 사용이 없다.
개인 입력과 점수는 현재 React 메모리에만 존재한다. 단어·정답은 클라이언트
번들에 있으므로 개발자 도구를 통한 정답 열람을 막는 시험 시스템은 아니다.
## 검증 경계
Vitest는 실제 RED/GREEN으로 데이터·채점·상태·오디오 그래프·화면을 검증한다.
Playwright는 production build와 실제 Chrome AudioContext로 두 크기의 10문제
완주·재청취·오류 복구·axe 검사를 수행한다. 테스트 자동화는 한국어 발음의
자연스러움, 실제 사용자의 청각 난이도, 모바일 Safari의 장치별 정책까지
증명하지 않으므로 인수 시 직접 청취를 추가한다.
+83
View File
@@ -0,0 +1,83 @@
# 오디오 출처와 재현
## 포함 자산
| 대상 | 생성 방식 | 원문/설정 | 배포 방식 |
| --- | --- | --- | --- |
| `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%이다.
물리적 음압을 측정하거나 장치 출력을 보정하지 않는다.
포먼트 합성 특성상 한국어 발음·억양과 웃음이 기계적으로 들린다. 배경도 실제
장소 녹음처럼 세밀하지 않다. 객관식의 자모 차이와 단어 친숙도, 발음의 자연스러움은
검수자가 직접 들어 평가해야 한다. 현재 단계 수치는 임상 검증이나 청력 판정용이 아니다.
+74
View File
@@ -0,0 +1,74 @@
# 정적 배포 인계
이 문서는 **Neo가 검토 후 수행할 절차**다. Codex는 서버, DNS, 인증정보에
접근하지 않았고 배포·서비스 생성·Git commit/push도 하지 않았다.
## 산출물
Node.js 최소 **22.22.2** (`>=22.22.2`)의 지원 LTS 환경에서 `npm ci`, `npm run lint`, `npm test`,
`npm run build`를 실행한다. `dist/` 전체를 하나의 릴리스로 전달한다.
`dist/audio/`의 WAV 101개와 manifest가 반드시 포함되어야 한다. 런타임은
Nginx 정적 파일만 필요하며 Node 서버·TTS 엔진·DB·환경변수·API 키는 필요 없다.
잠긴 jsdom 계열 의존성 때문에 24.x는 24.15.0 이상, 26.x는 26.0.0 이상이어야 한다.
브라우저 검증의 Chromium sandbox는 기본 활성화다. sandbox를 지원하지 않는
격리된 개발 환경에서만 `PLAYWRIGHT_NO_SANDBOX=1`로 명시적으로 끈다.
Canonical repository는 [OZ-Group/Find-Word](https://source.ibit.kr/OZ-Group/Find-Word)다.
프로젝트 자체는 private / all-rights-reserved이며 [LICENSE](../LICENSE)를 따른다.
React, ReactDOM, scheduler의 MIT 전문을 담은 `dist/THIRD_PARTY_NOTICES.md`도
반드시 배포한다. `build` 검증기는 고지 파일이 없거나 축약·변조되면 실패한다.
현재 앱은 도메인 루트(`/`) 배포 기준이다. 하위 경로 배포 시 Vite base뿐 아니라
음성 URL도 함께 변경하고 테스트해야 한다. 예정 도메인은 `findword.heart-m.com`이다.
## Nginx 예시
아래 root는 예시다. 실제 경로·기존 서버 블록·인증서는 Neo가 확인한다.
기존 서비스 설정을 덮어쓰지 않고 검토 후 적용한다.
```nginx
# 정적 릴리스 디렉터리를 가리킨다. 인증서와 HTTPS 설정은 운영자가 별도 추가한다.
server {
listen 80;
server_name findword.heart-m.com;
root /var/www/findword/current;
index index.html;
# 기본 MIME 정의로 JS/CSS/WAV가 올바른 타입으로 제공되게 한다.
include /etc/nginx/mime.types;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
# 파일명이 해시로 바뀌는 번들만 장기 캐시한다.
location /assets/ {
try_files $uri =404;
expires 1y;
}
# 음성 파일명은 재생성해도 같으므로 재검증한다. 누락 시 index.html 대신
# 404를 반환해야 앱이 명확한 음성 로드 오류와 재시도를 제공할 수 있다.
location /audio/ {
try_files $uri =404;
expires -1;
}
# SPA의 직접 진입·새로고침을 index.html로 받되 HTML은 재검증한다.
location / {
try_files $uri $uri/ /index.html;
expires -1;
}
}
```
## 인수 확인
1. 서버에 적용하기 전 `nginx -t`, 기존 서비스 충돌, 파일 읽기 권한을 확인한다.
2. TLS/HTTPS와 DNS를 운영자가 확인한다. 실제 도메인에서 시작 후 음성이 들리는지 확인한다.
3. `audio/intro.wav`와 목표 WAV가 정상 응답하고 존재하지 않는 오디오는 404인지 확인한다.
4. 모바일 Safari/Chrome, 데스크톱에서 음량·중단·다시 듣기·10문제 완주를 직접 확인한다.
5. 원자적으로 릴리스 디렉터리를 전환하고 이전 디렉터리를 보관해 되돌릴 수 있게 한다.
오프라인 캐시를 제공하는 PWA는 아니다. 정적 자산의 최초 로드에는 해당 서버
연결이 필요하다. 페이지·오디오 캐시가 서로 다른 버전으로 섞이지 않게 HTML과
음성의 재검증 정책을 유지한다.
+40
View File
@@ -0,0 +1,40 @@
# 단어와 오답 변형
임의의 한글 조합을 자동 생성하지 않고 사전에 있는 표제어만 고른다. 다음의
짧은 의미 구분은 앱 작성자가 작성한 설명이며 사전의 정의·예문·음성을
복제한 데이터셋이 아니다. 표기 단어는 `src/content.ts`에 명시적으로 저장한다.
| 정답 | 초성 교체 | 모음 교체 | 세 번째 변형 |
| --- | --- | --- | --- |
| 사과 | 다과 (차와 간식) | 사고 (뜻밖의 사건) | 사자 (동물), 음절 교체 |
| 바다 | 사다 (구매하다) | 보다 (눈으로 살피다) | 받다 (수령하다), 받침 추가 |
| 나무 | 나루 (배가 오가는 곳) | 너무 (정도가 지나치게) | 나물 (먹는 풀), 받침 추가 |
| 모자 | 포자 (생물의 번식 단위) | 모조 (원본을 본뜸) | 목자 (양을 돌보는 사람), 받침 추가 |
| 우산 | 부산 (도시 이름) | 오산 (잘못된 계산) | 우상 (숭배의 대상), 받침 교체 |
| 시장 | 지장 (일을 방해하는 사정) | 사장 (회사 책임자) | 신장 (몸의 키), 받침 추가 |
| 기름 | 이름 (대상을 부르는 말) | 거름 (식물에 주는 영양) | 기틀 (기본 구조), 음절 교체 |
| 소리 | 오리 (새) | 수리 (고침) | 소금 (조미료), 음절 교체 |
| 고기 | 보기 (예시) | 고개 (목 위의 머리) | 공기 (대기), 받침 추가 |
| 가구 | 하구 (강이 바다와 만나는 곳) | 가교 (연결하는 다리) | 강구 (방안을 찾음), 받침 추가 |
동사·부사는 사전 표제어 형태를 사용하고 부산은 사전에 등재된 고유명사로
사용한다. 문장의 의미 적합성을 오답 생성 규칙으로 삼지 않고 **들은 낱말의
소리 차이**를 묻는다. 이는 의미상 동의어가 아니라 음운상 비슷한 오답이다.
참조 사전은 국립국어원의 [한국어기초사전](https://krdict.korean.go.kr/kor/mainAction),
[표준국어대사전](https://stdict.korean.go.kr/main/main.do),
[우리말샘](https://opendict.korean.go.kr/)이다. 혼동하기 쉬운 일부 표제어의
직접 확인 링크는 [하구](https://krdict.korean.go.kr/kor/dicSearch/SearchView?ParaWordNo=85229),
[목자](https://krdict.korean.go.kr/eng/dicSearch/SearchView?ParaWordNo=55879),
[부산](https://krdict.korean.go.kr/kor/dicSearch/SearchView?ParaWordNo=68895)이다.
[국립국어원의 나루 설명](https://www.korean.go.kr/front/mcfaq/mcfaqView.do?mcfaq_seq=8815)도 참고한다.
앱 실행 중에는 사전에 접속하지 않는다.
`tests/content.test.ts`는 한글 완성형을 초성·중성·종성으로 분해한다. 초성·모음·받침
변형은 정확히 한 자모만 바뀌는지 확인한다. 음절 교체는 더 큰 차이를 허용한다.
받침 추가도 종성 0에서 다른 값으로 바뀌는 ‘받침 교체’로 분류한다.
`RULE_DIFFICULTY`는 음절 1, 초성 2, 모음 3, 받침 4의 예상 혼동 순서를
기록한다. 각 묶음의 세 오답은 서로 다른 규칙과 순위를 가진다. 이 순서는
출제 메타데이터이며 청감·발음·청력에 대한 실측 난이도나 임상 척도가 아니다.
새 단어를 넣을 때는 표제어·의미·변형 규칙을 검토하고 이 목록을 갱신한다.