172 lines
11 KiB
Markdown
172 lines
11 KiB
Markdown
# Find-Word v0.2.5 설계
|
||
|
||
## 데이터와 출제
|
||
|
||
`src/content.ts`는 패턴, 질문, 단어, 오답 규칙, 현장음 URL, 상대 dB를 서로 다른
|
||
배열로 보관한다. `renderSentence`가 각 패턴의 `{word}`를 정답으로,
|
||
`{distractor1}`·`{distractor2}`를 해당 보기의 오답으로 채운다. 같은 함수를
|
||
음성 생성기도 사용하며 런타임 문장과 WAV의 대응은 manifest 테스트가 검증한다.
|
||
|
||
`Pattern.mechanic`은 다음 10개 고유 청취 연산을 나타낸다. 인용 단어를 활용형으로
|
||
바꾸지 않으므로 동사·부사 표제어가 오답으로 나와도 문장이 자연스럽다.
|
||
|
||
| mechanic | 청취 과제 |
|
||
| --- | --- |
|
||
| `direct-target` | 한 개의 목표 단어를 직접 식별 |
|
||
| `distractor-password` | 연습 암호를 제외하고 진짜 암호 선택 |
|
||
| `ignore-then-remember` | 앞 단어를 잊고 이후에 지시된 단어 기억 |
|
||
| `first` | 세 단어 중 처음 선택 |
|
||
| `last` | 세 단어 중 마지막 선택 |
|
||
| `middle` | 세 단어 중 가운데 선택 |
|
||
| `repetition` | 오답 사이에서 두 번 등장한 단어 찾기 |
|
||
| `correction` | 처음 답을 취소하고 정정된 답 선택 |
|
||
| `conditional-cue` | 초록/빨강 규칙과 현재 신호를 함께 적용 |
|
||
| `spatial-right-cue` | 좌우 표지 중 오른쪽에 해당하는 단어 선택 |
|
||
|
||
방향 단서는 문장의 의미이며 stereo 위치 재현 과제는 아니다. 모든 음성은 mono다.
|
||
직접 식별을 제외한 문장은 실제 보기의 경쟁 단어도 발화하므로, 들린 단어 하나를
|
||
무조건 고르는 것으로 해결되지 않는다. 테스트는 100개 문장 전체에서 단어 순서,
|
||
목표 반복 횟수, 취소·조건·방향 단서와 질문 의도를 검증한다.
|
||
|
||
`src/game.ts`의 `createQuestions`는 seed 기반 PRNG와 Fisher–Yates로
|
||
유형·단어·정답 위치를 섞는다. 한 라운드는 3문항이며 유형과 정답 단어가 중복되지 않는다. 시작 단계는 1~5로
|
||
정규화하며 세 문항에 같은 level/db를 적용한다. 혼합 모드는 선택/입력/선택이다.
|
||
환경 순환은 seed로 여덟 곳을 섞어 세 곳을 선택한다. 100개 문장 조합에 응답 방식·환경·난이도를 독립적으로 결합한다.
|
||
|
||
오답의 `rule`은 `RULE_DIFFICULTY`에 연결된다. 음절 교체 1, 초성 교체 2,
|
||
모음 교체 3, 받침 교체 4는 편집상 예상 혼동 순서이며 검증된 청각 척도가
|
||
아니다. 모든 문항에 서로 다른 세 변형을 포함한다. 음량 단계는 이 값과 분리된다.
|
||
|
||
## 게임 상태
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> ready
|
||
ready --> loading: PLAY
|
||
loading --> playing: STARTED
|
||
loading --> ready: ERROR
|
||
playing --> ready: ERROR / 중단
|
||
playing --> answer: ENDED
|
||
answer --> loading: 다시 듣기
|
||
answer --> feedback: ANSWER
|
||
feedback --> ready: NEXT (다음 문제)
|
||
feedback --> results: NEXT (세 번째 문제)
|
||
ready --> results: EXIT
|
||
loading --> results: EXIT
|
||
playing --> results: EXIT
|
||
answer --> results: EXIT
|
||
feedback --> results: EXIT
|
||
```
|
||
|
||
정상 결과는 다음 단계(최대 5)의 새 라운드로 연결되며 중도 종료는 응답 수·정답 수를
|
||
표시한 뒤 홈으로 연결된다. 무응답 정답률은 `— / 응답 없음`, 응답하지 않은 단계는 `미진행`으로 표시한다.
|
||
중도 종료 제목과 현재 단계 완료 제목을 구분한다. 진행 막대는 답변 수가 아닌 현재 문항의 1/3, 2/3, 3/3이다.
|
||
결과 단계는 모든 지연 이벤트를 무시한다.
|
||
SNR은 −12~+12 dB 정수로 제한하고 NaN/Infinity는 단계 기본값으로 복구한다.
|
||
수동 SNR은 재생 도중 현재 gain을 바꾸지 않고 현재 재청취/남은 문항/다음 라운드에
|
||
적용한다. `heardDb`는 실제 재생 시작값을 보존해 결과를 정확히 기록한다. 홈은
|
||
수동 SNR·답·입력·IME·토큰을 초기화하고 시작 단계·모드·환경·전체 음량은 유지한다.
|
||
|
||
허용되지 않은 이벤트는 동일 상태를 반환한다. 재생 전/중에는 채점할 수 없고,
|
||
피드백 이후의 중복 답변·다음 클릭도 한 번만 반영된다. UI에서도 버튼 비활성화와
|
||
동기 ref 잠금을 함께 사용한다. 세션 토큰으로 처음 화면 복귀·unmount 뒤 완료된
|
||
Promise를 무시한다. 질문과 보기는 재생 완료 후 DOM에 생성되며 정답 문장은
|
||
채점 후에만 표시된다.
|
||
|
||
주관식은 NFC 한글 정규화 후 공백과 유니코드 문장부호만 제거한다. 비어 있는
|
||
답변과 부분 일치는 정답으로 인정하지 않는다. React 텍스트 노드로만 출력하여
|
||
입력을 HTML로 해석하지 않는다.
|
||
|
||
점수는 정답 시 `100 × 단계 + 10 × min(연속 정답 - 1, 4)`이다. 오답은 0점,
|
||
현재 연속 정답을 초기화한다. 기존 공식의 cap은 40점이며 세 문항 라운드에서는 문항별 최대 20점, 라운드 합계 최대 30점(0 + 10 + 20)이다. 1단계 세 정답은 330점이다. 최고 연속 정답과 난이도별 정답 수는 유지한다.
|
||
|
||
## 오디오
|
||
|
||
101개 저장 WAV는 edge-tts 7.3.1의 `ko-KR-HyunsuMultilingualNeural`로
|
||
빌드 전에 생성한다. 24kHz mono PCM16이며 원문·도구·해시는 manifest에 남긴다.
|
||
생성기는 임시 디렉터리에서 전체 검증 후 원자 교체한다. 생성 절차는
|
||
[AUDIO_PROVENANCE](AUDIO_PROVENANCE.md)에 있으며 앱은 provider에 연결하지 않는다.
|
||
|
||
`src/audio/dsp.ts`는 순수 수치 함수이며 장치가 없어도 테스트할 수 있다.
|
||
8개 현장 녹음 WAV를 디코드해 재생한다. 각 12초, 24kHz mono PCM16이며 양끝
|
||
50ms fade로 루프 접합 클릭을 줄인다. 절차적 잡음 합성 코드는 제거했다.
|
||
|
||
`src/audio/engine.ts`는 클릭 이벤트에서 AudioContext 생성·resume을 호출한 뒤
|
||
같은 출처의 WAV를 로드한다. `integrity.ts`가 JS 번들에 고정한 109개 URL·bytes·SHA-256과 응답을
|
||
디코드 전에 대조한다. 런타임 manifest fetch나 외부 요청은 없다. mono·길이·유한 sample·non-silence와
|
||
장치 rate로 리샘플한 PCM의 경로별 peak 상한도 확인한다. 변조/상한 초과는 재시도 가능한 오류이며
|
||
파일별 자동 감쇠로 덮지 않는다.
|
||
|
||
배경은 새 FFmpeg ebur128 실측에서 구한 double precision 스칼라 volume으로 **−32.5 LUFS**에 맞춘 저장 PCM을 그대로 사용한다.
|
||
최종 ebur128 목표 ±0.2 LU/spread ≤0.4 LU/true peak ≤−6 dBTP를 강제한다. 생성 내부 ceiling은 −6.2 dBTP다.
|
||
독립 pyloudnorm 0.2.0도 통과했다. 새 market의 VAD ASR 실패는 불변 진단으로 남기고,
|
||
릴리스 gate는 자동 empty-ASR OR [exact-byte Oz 청취 승인](../.agent/RELEASE-APPROVAL-v0.2.5.json)을 검사한다.
|
||
현재는 후자만 통과한다. `scripts/release_approval.py`가 전달 ZIP·manifest·8 WAV·notice 바이트와
|
||
문구/시각/범위를 검증하며 `legalPrivacyClearance=false`를 강제한다.
|
||
voice와 intro만 기존 RMS **0.03** 정규화를 유지한다. `g(db)=10^(db/20)`은 목표 음성 상대 gain이며
|
||
배경마다 RMS가 다르므로 표시 SNR은 기준 gain의 dB 변화다. 실제 파일 RMS 비율/순간 청감/dB SPL은 보장하지 않는다.
|
||
|
||
고정 상한 `B=0.55`, `V=0.25`, `I=0.20`, `SNR_MAX=12`, 안내 시작 배율 `A=1.8`, 합성 ceiling `C=0.8`을 사용한다.
|
||
24kHz 실제 최대값은 배경 0.47195435, RMS 정규화 voice 101개 0.20529779, intro 0.14995423이다.
|
||
새 자산의 실제 Chrome 24/44.1/48/96kHz 전수 디코드 최대값은 배경 0.48797759,
|
||
voice 0.20585650, intro 0.14999594다.
|
||
[review-fixes 브라우저 증거](../.agent/evidence-v0.2.5/review-fixes/browser-summary.json)에 전체 측정을 보존했다.
|
||
여유를 둔 상한은 이 모든 값보다 크며 재생 때도 검사한다. 실제 GainNode의 Float32 plateau는
|
||
0.4198884665966034로 환경/SNR에 걸쳐 동일하고 JS 예약값과의 차이는 Float32 반올림이다.
|
||
|
||
```text
|
||
Q = B + V × 10^(12/20) + I × A = 1.905267926383743
|
||
G = min(1, C/Q) = 0.4198884518664126
|
||
backgroundGain = G
|
||
voiceGain(db) = G × 10^(db/20)
|
||
introGain = G × 1.8 (기존 ramp로 1/15까지 감소)
|
||
|mix(t)| ≤ master × G × (B + V × 10^(db/20) + I × A) ≤ 0.8
|
||
```
|
||
|
||
삼각부등식이 모든 위상·loop offset·세 경로가 동시에 겹치는 경우까지 덮는다. master 범위는 [0,1],
|
||
배경 fade는 [0,G], intro ramp도 [0,G×1.8] 이내이므로 상한을 키우지 않는다. intro가 없어도 같은 G를 사용한다.
|
||
환경·실제 sample peak·현재 SNR을 G 계산에 넣지 않는다. SNR은 voice만 바꾸고 master는 모든 경로를 함께 바꾼다.
|
||
`mixing-loudness.test.ts`는 101×8×25=20,200개 조합에서 유한 gain·비율·peak 상계를 검사한다.
|
||
이는 디지털 샘플 안전성이다. DAC의 재구성 필터·기기 음압·임상 청감의 보장은 아니다.
|
||
|
||
`src/audio/assets.ts`는 음성 자산 버전과 URL 생성을 한 곳에서 관리한다.
|
||
intro·목표 100개·현장음 8개 모두 `?v=0.2.5`을 붙여 동일 파일명에 남은 이전 캐시를
|
||
피한다. 파일을 다시 생성하면 제품 버전 유지 여부와 관계없이 자산 버전을
|
||
갱신한다. 버전 확인을 위한 외부 요청이나 런타임 manifest 요청은 없다.
|
||
|
||
```text
|
||
배경 BufferSource → 배경 Gain (고정 G, 시작/끝 fade) ─┐
|
||
목표 BufferSource → 음성 Gain (고정 G × 10^(dB/20)) ─────┼→ master Gain → destination
|
||
안내 BufferSource → 안내 Gain (고정 G × 1.8 → 0.12) ─────┘
|
||
```
|
||
|
||
배경 소스는 `loop=true`이며 목표 ended에서 즉시 stop/disconnect한다.
|
||
각 라운드의 첫 문제에서는 웃음과 안내 음성을 낮추어 배경에 묻히게 한 후 목표 문장을
|
||
재생한다. 이후에는 목표만 재생한다. 안내 도중 실패했으면 재시도 시 안내도
|
||
다시 재생한다. 기본 master는 35%이며 음량 조절은 두 신호의 비율을 유지한다.
|
||
|
||
동시 play는 거부한다. 정지·실패·완료는 onended 해제, 소스 stop/disconnect,
|
||
AbortController 취소, 타이머 제거를 수행한다. load/decode 뒤에도 작업 토큰을
|
||
검사하여 취소된 이전 작업이 소리를 시작하지 못하게 한다. 준비 15초,
|
||
재생 길이 + 5초의 제한으로 무한 대기를 피한다. unmount는 context까지 닫는다.
|
||
|
||
## 화면·접근성·보안
|
||
|
||
`App.tsx`는 시작·플레이·결과 화면, 음량, 모드, 배경 선택을 담당한다.
|
||
정답/피드백/결과 제목으로 초점을 이동하고 결과표에 caption에 해당하는
|
||
접근성 이름과 scope를 둔다. 네이티브 radio/select/button/form으로 방향키,
|
||
Tab/Enter와 터치 조작을 지원한다. 장식 파형·캐릭터는 aria-hidden이며
|
||
reduced motion에서는 파형이 움직이지 않는다. 오류는 role=alert로 전달한다.
|
||
|
||
외부 폰트, CDN, 이미지, 분석 도구, 로그인, 쿠키, localStorage 사용이 없다.
|
||
개인 입력과 점수는 현재 React 메모리에만 존재한다. 단어·정답은 클라이언트
|
||
번들에 있으므로 개발자 도구를 통한 정답 열람을 막는 시험 시스템은 아니다.
|
||
|
||
## 검증 경계
|
||
|
||
Vitest는 실제 RED/GREEN으로 데이터·채점·상태·오디오 그래프·화면을 검증한다.
|
||
Playwright는 production build와 실제 Chrome AudioContext로 두 크기의 3문항
|
||
완주·다음 단계·SNR·중도 종료·재청취·오류 복구·axe 검사를 수행한다. 테스트 자동화는 한국어 발음의
|
||
자연스러움, 실제 사용자의 청각 난이도, 모바일 Safari의 장치별 정책까지
|
||
증명하지 않으므로 인수 시 직접 청취를 추가한다.
|