Xhadow
AI 음성 클론과 브라우저 내 음향 분석을 결합해, 점수 대신 조음을 교정하는 발음 코치
- Hybrid DTW로 시간축 정렬 18배 가속 (역변환 오차 0)
- 422ms → 23ms
- 브라우저 내 포먼트 추출 음소 수준 오차
- ±10Hz
- 음소 필터링과 시계열 축소로 줄인 LLM 입력 토큰 (코칭 품질 동일)
- −80%
- 모국어별 L1 간섭 패턴 (한·일·중·스·독·불)
- 6
주요 내용
- Web Audio API 기반 브라우저 DSP 파이프라인 — 서버 왕복 없이 클라이언트에서 실시간 처리합니다.
- 포먼트 시계열을 혀·턱·입술의 행동 지시로 번역하는 프롬프트를 설계했습니다.
- 음소 이력을 바탕으로 개인별 취약 음소 연습 코스를 생성합니다.
- 긴 AI 작업은 Background Job 패턴으로 비동기 처리하고, 페이지를 벗어났다 돌아와도 상태를 복구합니다.
사용자 본인의 목소리를 복제해 목표 언어로 말하게 하고, 그 레퍼런스와 사용자의 발화를 음향적으로 비교해 조음을 교정하는 발음 코치. 브라우저에서 FFT와 포먼트와 피치와 유성성을 직접 계산하고, 두 오디오의 시간축을 단어 앵커 기반 DTW로 정렬한 뒤, 정렬된 시계열을 LLM에 넘겨 혀·턱·입술의 동작 지시로 번역합니다. 기획과 개발과 디자인을 혼자 수행했습니다.
문제
발음 앱은 대부분 점수를 반환합니다. 문장을 읽으면 73점이 나오고 거기서 끝납니다.
이 점수에는 기준점이 없습니다. 비교 대상은 성도 구조도 음역대도 말하기 속도도 다른 원어민이고, 그 녹음과 사용자 발화 사이 음향 차이의 상당 부분은 발음 오류가 아니라 신체 구조 차이입니다. 점수를 낮춘 원인이 조음 오류인지 화자 특성인지 분리되지 않으면 무엇을 어떻게 바꿔야 하는지에 답할 수 없습니다.
Xhadow는 이 교란 변수를 제거합니다. 사용자 본인의 목소리를 복제해 목표 언어를 원어민 억양으로 말하게 하고, 사용자를 자기 자신과 비교합니다. 음색과 음역대와 성도 특성이 양쪽에서 고정되므로 차이 신호에 남는 것은 조음과 타이밍입니다.
그 비교를 성립시키는 일이 신호처리 문제이고, 이 코드베이스의 대부분이 그 문제를 다룹니다.
설계 원칙 — 측정은 결정론, 생성은 번역만
LLM은 오디오를 보지 않습니다. 음소 단위 점수, 실제로 들린 대치음, 지속시간 편차, 정렬된 F1/F2/F3 궤적은 모두 프롬프트를 조립하기 전에 결정론적으로 계산됩니다. 모델이 받는 것은 그렇게 계산된 값의 표입니다.
이 경계는 실패 모드를 제한하기 위한 것입니다. 멀티모달 모델에 원본 오디오를 직접 넣으면 음소 수준에서 정확도가 떨어지는데, 출력은 여전히 확신에 찬 문장으로 나옵니다. 근거 없이 생성된 코칭은 틀렸다는 사실조차 검증할 수 없습니다.
생성 단계에 남긴 일은 하나입니다. 이미 측정된 차이를 학습자의 언어로 된 조음 지시로 번역하는 것. 모델은 조언을 나쁘게 표현할 수는 있어도, 측정되지 않은 차이를 만들어낼 수는 없습니다. 그래서 코칭이 이상할 때 원인을 프롬프트와 측정값 중 어디에서 찾아야 하는지가 항상 결정됩니다.
파이프라인
1. 레퍼런스 — 내 목소리로 만든 목표 발화
음성 복제는 Cartesia를 통한 2단계입니다. 복제와 억양 전이가 별개의 연산이기 때문입니다.
| 단계 | 호출 | 역할 |
|---|---|---|
| 복제 | POST /voices/clone |
이미 유창한 언어로 녹음한 5–10초 클립 하나로 즉시 클로닝 |
| 현지화 | POST /voices/localize |
그 음색을 목표 언어와 방언과 화자 성별로 전이 (42개 언어) |
현지화된 voice_id만 저장하고 중간 베이스 보이스는 즉시 삭제합니다. 합성은 sonic-3으로 raw PCM을 스트리밍하며, add_timestamps: true로 단어 단위 타임스탬프를 함께 받습니다. 이 타임스탬프는 하이라이팅용 장식이 아니라 4단계 정렬을 계산 가능하게 만드는 앵커 집합입니다.
2. 음소 단위 평가
녹음은 브라우저에서 16 kHz · 16-bit · 모노 PCM으로 직접 캡처합니다. MediaRecorder는 기기 네이티브 샘플레이트의 WebM/Opus를 반환하고 Azure 발음 평가는 정확히 위 포맷을 요구하므로, AudioContext({ sampleRate: 16000 })으로 리샘플링을 브라우저에 위임하고 44바이트 RIFF 헤더를 직접 씁니다.
평가 설정은 최대 해상도로 고정합니다. 100점 척도, 음소 단위 granularity, enableMiscue로 누락과 삽입 탐지, prosody 평가, 그리고 nbestPhonemeCount: 5입니다.
마지막 항목이 핵심입니다. 기대 음소에 점수를 매기는 것을 넘어, Azure가 실제로 들은 상위 5개 음소를 반환합니다. 한국어 화자가 rice를 말했는데 /ɹ/ 자리에서 /l/이 들렸다면 앱은 점수가 낮다는 사실이 아니라 어떤 대치가 일어났는지를 압니다. 진단과 처방은 점수가 아니라 이 대치 정보에서 나옵니다.
3. DSP 프론트엔드 — 의존성 없이 직접 구현
fft.js도 meyda도 dsp.js도 쓰지 않았습니다. 아래 변환은 전부 직접 작성해 브라우저에서 실행됩니다.
- 프레이밍 —
fftSize2048,hopSize512 (44.1 kHz 기준 46.4 ms 윈도우, 11.6 ms 홉) - 윈도우 — 대칭 Hann
- FFT — 비트 리버설 순열 후 버터플라이 스테이지를 적용하는 in-place iterative radix-2 Cooley–Tukey, 약 60줄
- F0 — 시간영역 자기상관, lag 범위
sr/500부터sr/50까지 - 포먼트 — 스펙트럼 피크 피킹 후 대역 제약으로 F1/F2/F3 선택
- 음색 — spectral centroid, flatness, 85% rolloff
포먼트는 200–4000 Hz에서 국소 최댓값 상위 10개를 고른 뒤 순서 제약을 걸어 선택합니다. F1은 200–1000 Hz의 최대 크기, F2는 800–2500 Hz에서 F1 + 200 Hz 초과, F3는 2000–4000 Hz에서 F2 + 200 Hz 초과입니다.
무성 구간과 침묵 구간에서도 피크 피킹은 그럴듯한 값을 반환합니다. 그래서 포먼트 값을 신뢰해도 되는지 판단하는 게이트를 따로 뒀습니다. 값싼 특징 세 개의 가중 결합입니다.
confidence = pitchConf × 0.5 + max(0, 1 − zcr/0.15) × 0.3 + min(1, rms/0.02) × 0.2
마찰음은 영교차율이 높고 침묵은 에너지가 없다는 두 사실을 각각 하나의 항으로 넣은 것입니다. 이 값은 표시용이 아닙니다. 음소 경계 보정이 0.35 교차점을 모음↔자음 전이점으로 사용합니다. 먼저 음소 경계를 균등 분할한 뒤 내부 경계를 평균 음소 길이의 ±40% 안에서 움직여, 유성성이 0.35에 가장 가까운 프레임에 붙입니다.
4. Hybrid Segmented DTW
사용자 발화와 레퍼런스는 길이가 다르고 그 불일치는 균일하지 않습니다. 앞 절은 서둘렀고 마지막 단어는 늘어집니다. 전역 속도 비율 하나로는 표현할 수 없고, 두 타임라인 사이의 단조 비선형 워핑 함수가 필요합니다. Dynamic Time Warping이 계산하는 것이 그 함수입니다.
두 스펙트로그램 전체에 DTW를 돌리는 방식은 두 가지 이유로 기각했습니다.
- 비용. 10초 발화면 약 1000 프레임이고 DP 테이블은 O(N·M)입니다. 100만 셀 각각이 1024차원 코사인 거리이며, 번들된 스트레스 하니스에서 메인 스레드를 422 ms 블로킹합니다. 길이에 대해 제곱으로 증가합니다.
- 드리프트. DTW는 전역 경로를 최적화합니다. 기침이나 실내 반사나 마찰음 버스트 하나가 정렬을 틀어놓으면 이후 모든 프레임이 그 오차를 물려받습니다. 경로를 언어적 실체에 고정해주는 장치가 없습니다.
해법은 각 단어가 어디서 시작하고 끝나는지를 이미 양쪽 모두에서 알고 있다는 사실을 쓰는 것입니다. 레퍼런스는 Cartesia 단어 타임스탬프, 발화는 Azure 단어 오프셋에서 나옵니다. 이 앵커는 스펙트럼 거리보다 훨씬 신뢰할 수 있습니다. 그래서 하나의 큰 정렬 문제를 작고 독립적으로 상계가 잡히는 문제들의 사슬로 분해했습니다.
목표 단어마다 다음을 수행합니다.
- 대소문자와 문장부호를 제거한 텍스트로 대응하는 발화 단어를 찾습니다.
- Azure 센티초 span을 초로 변환하고 양쪽 시간 구간을 프레임 인덱스로 매핑합니다.
- 어느 한쪽이라도 100 프레임을 넘으면 동일한 factor로 양쪽을 데시메이션해 국소 시간축의 일관성을 유지합니다.
- flat row-major
Float32Array위에서 표준 DP를 수행합니다. 코사인 거리, 3-이웃 스텝, 단위 전이 가중치입니다. - 누적 비용을 대각 우선 tie-break으로 역추적합니다.
- 경로에서 약 10개 점을 뽑아 단어의 실제 시간 구간 안으로 선형 재투영하고, 단어 시작과 끝 앵커를 앞뒤에 붙입니다.
결과는 정렬된 구간 선형 양방향 타임맵입니다. 같은 하니스에서 전역 경로 422 ms 대비 23 ms에 완료되며, 정렬 오차는 구조적으로 그것을 유발한 단어 안에 갇힙니다. 조회는 이진 탐색과 선형 보간으로 레퍼런스→발화, 발화→레퍼런스 양방향을 지원하고 왕복 역변환 오차는 0.000000초입니다.
이 구조가 이 프로젝트에서 가장 중요한 결정입니다. 정렬을 언어 단위에 고정했기 때문에 이후의 모든 비교가 “같은 소리끼리” 이뤄집니다.
5. 정렬이 만들어내는 것
타임맵 하나에서 네 가지 결과가 나옵니다.
WSOLA 시간 워핑 재생. 사용자 녹음을 레퍼런스 타임라인 위로 재합성해 두 오디오를 프레임 단위로 맞춘 채 비교할 수 있게 합니다. 40 ms Hann grain을 50% 오버랩으로 배치하고, 출력 grain마다 타임맵에서 이상적인 입력 위치를 조회한 뒤 그 주변 ±15 ms를 교차상관으로 탐색해 직전 grain에 가장 잘 이어지는 지점을 고릅니다. 이 유사도 탐색이 단순 리샘플링에서 발생하는 피치 주기 불연속을 막습니다. 결과적으로 피치는 그대로 두고 시간만 워핑됩니다.
예측형 재생 속도 서보. 동기 재생 중에는 오디오를 리샘플링하지 않고 브라우저의 피치 보존 playbackRate를 정렬 함수의 수치 미분으로 구동합니다. 0.1초 앞을 조회해 필요한 속도를 구하고 0.5–2.0으로 클램프합니다. 드리프트가 50 ms를 넘으면 하드 리싱크하며, 속도가 귀에 거슬리게 떨리지 않도록 히스테리시스를 뒀습니다.
음소별 지속시간 편차. Azure는 발화 음소의 지속시간을 주고, 타임맵은 그 구간이 레퍼런스 타임라인에서 차지하는 길이를 줍니다. 그 차이가 실제 편차이며 80 ms 또는 50%를 넘으면 플래그가 붙습니다.
궤적 비교. 임의의 음소에 대해 발화와 레퍼런스의 F1/F2/F3와 유성성 트랙을 타임맵으로 정렬한 뒤 고정 30포인트 그리드로 리샘플링합니다. 길이가 다른 두 모음을 포인트 단위로 직접 비교할 수 있게 됩니다. 평균값 하나로는 /aɪ/나 /eɪ/ 같은 이중모음의 변화 궤적이 사라지고, 궤적이 사라지면 “혀가 어디에서 어디로 움직여야 하는가”를 말할 수 없습니다.
6. 음향 근거 팩과 출력 계약
LLM이 받는 것은 위 과정에서 조립된 구조화된 표입니다. 음소마다 점수, 실제로 들린 대치음, 레퍼런스 대비 지속시간 편차, 30포인트 중 3프레임마다 뽑은 발화·목표 포먼트 궤적, 유성성 델타가 들어갑니다.
여기에 학습자 모국어별 L1 대조음운론 사전지식을 함께 넣습니다. 한국어·일본어·중국어·스페인어·독일어·프랑스어 6개 언어에 대해, 해당 언어 화자가 일으키는 것으로 알려진 구체적 간섭 패턴을 명시했습니다. 한국어 화자의 /r/–/l/ 혼동은 한국어의 유음이 위치에 따라 실현이 달라지는 하나뿐이기 때문에 발생하고, 일본어 화자의 같은 오류는 두 소리 사이에 있는 탄음 하나 때문에 발생합니다. 같은 오류라도 원인이 다르면 교정 지시가 달라야 합니다.
프롬프트에는 두 개의 금지 규칙이 있습니다. F1/F2/F3나 포먼트라는 단어를 출력에 쓰지 않을 것, 그리고 관찰된 차이를 사용자가 실제로 수행할 수 있는 동작으로 바꿀 것입니다. 번역 규칙은 조음음성학의 대응관계를 그대로 고정했습니다. F1은 턱 개방도, F2는 혀의 전후 위치, F3은 입술 원순화에 대응합니다. “F2가 목표보다 190 Hz 낮습니다”는 내부 근거로만 쓰이고 출력은 “혀가 너무 뒤에 있어 /æ/가 /e/에 가깝게 들립니다”가 됩니다.
토큰 예산은 프롬프트 조립 전에 통제합니다. 점수가 100점이면서 동시에 레퍼런스 지속시간의 30% 이내인 음소는 프롬프트에서 제외하고, 남은 음소의 궤적도 3프레임당 1포인트만 보냅니다. 입력 크기가 문장 길이가 아니라 실제 문제 개수에 비례하게 됩니다.
모델은 temperature: 0.2와 response_format: { type: "json_object" }로 고정하고, 단어별 이슈 배열과 각 이슈의 진단·교정·중요도를 담은 정해진 계약을 반환하게 합니다.
진단 이후
진단만으로 끝나면 제품이 아닙니다.
조음 시각화. F1/F2/F3를 혀 높이·혀 전후·입술 원순화·턱 개방도로 매핑해 시상면 입 모양 애니메이션과 22종 비짐 이미지를 구동합니다. 텍스트 교정만으로 이해하기 어려운 학습자에게 같은 정보를 다른 감각 채널로 전달합니다.
적응형 연습. 최근 100회 시도의 평가 결과에서 음소별 등장 횟수와 평균 점수를 집계하고, 3회 이상 등장한 음소 중 평균 점수 하위 5개를 뽑아 그 소리가 밀집된 연습 스크립트를 생성합니다. 시도마다 정규화된 Azure 결과 전체를 Json 컬럼에 저장해 뒀기 때문에 별도 분석 파이프라인 없이 가능합니다.
문맥 인식 튜터. 직전 시도의 음소 점수를 이미 알고 있는 스트리밍 채팅입니다. “왜 내 /r/은 계속 틀리지?“에 일반론이 아니라 해당 사용자의 측정값으로 답합니다.
Course Studio. URL, 유튜브 자막, 실시간 뉴스 검색, 업로드한 PDF와 Word, 자유 텍스트에서 코스를 생성합니다. 생성한 코스는 공개 카탈로그에 게시하고 포크하고 평가할 수 있습니다.
엔지니어링 결정
노이즈 억제를 켜지 않습니다. getUserMedia의 echoCancellation, noiseSuppression, autoGainControl을 강제로 켜지 않고 브라우저 기본값에 둡니다. 이 DSP 체인들이 포먼트 구조와 다이내믹 레인지를 바꾸는데, 그것이 지금 측정하려는 신호이기 때문입니다.
렌더링 기술 네 가지를 의도적으로 병용합니다. 스펙트로그램은 리드로마다 약 90,000픽셀을 쓰므로 픽셀 단위 putImageData를 씁니다. 모음 공간 차트는 CSS 트랜지션과 히트 테스트가 필요한 요소가 약 30개뿐이라 인라인 SVG입니다. 보이스 오브는 프래그먼트 셰이더 그 자체라 WebGL이고, 보이스 ID 해시로 시드해 클론마다 결정적인 시각 아이덴티티가 생깁니다. 점수 추이 차트 한 곳에만 차트 라이브러리를 썼습니다.
긴 작업은 잡 테이블을 거칩니다. URL로부터 코스를 생성하면 LLM 호출과 TTS 호출과 여러 DB 쓰기로 30초를 넘겨 요청 예산을 벗어납니다. 작업은 진행률과 사람이 읽을 수 있는 상태 문구를 가진 행으로 기록되고 클라이언트가 2초마다 폴링합니다. 잡 ID는 localStorage에 있어 생성 도중 새로고침해도 실행 중인 작업에 다시 연결됩니다.
Server Action은 공개 API 표면입니다. "use server" 모듈의 모든 export는 요청만 조작하면 어느 브라우저에서든 호출할 수 있습니다. 세션 없이 서버 대 서버로 실행돼야 하는 함수, 특히 백그라운드 작업이 호출하는 함수는 lib/에 두고 인가는 호출자가 책임집니다. 라우트 핸들러용 게이트는 인증과 레이트 리밋 스코프를 같은 호출에서 반환합니다. 인증은 누가에 답할 뿐이고 과금되는 업스트림 앞에서는 얼마나 자주도 필요하기 때문입니다.
녹음은 비공개, 코스 이미지는 공개입니다. 목소리 녹음이 URL 유출만으로 열려서는 안 되므로 버킷을 비공개로 두고 모든 읽기를 인가 프록시로 보냅니다. 호출자를 인가한 뒤 서버리스 함수로 바이트를 흘려보내는 대신 5분짜리 서명 URL로 리다이렉트합니다. 객체 키를 <userId>/…로 네임스페이싱해 첫 경로 세그먼트에 대한 RLS 정책 하나가 전체 트리를 커버합니다.
레이트 리미터는 Postgres에 있습니다. Redis가 없고 서버리스 인스턴스는 인메모리 카운터를 공유할 수 없으므로 DB가 유일한 공유 상태입니다. (키, 윈도우)마다 행 하나를 두고 이전 윈도우를 남은 겹침 비율만큼 가중해 롤링 카운트를 추정합니다. 고정 윈도우와 같은 비용으로 경계를 넘나들며 두 배 쿼터를 연속으로 쓰는 것을 막습니다.
검증
번들된 합성 하니스로 각 계층이 의도대로 동작하는지 확인했습니다. 아래는 이 문서를 쓰면서 다시 실행한 결과입니다.
| 대상 | 방법 | 결과 |
|---|---|---|
| 포먼트 추출 | 720 / 1250 / 2550 Hz 피크를 넣은 합성 스펙트럼 | F1 9.4 Hz, F2 1.1 Hz, F3 9.1 Hz 오차 |
| 유성성 게이트 | /s/·/z/·/a/에 대응하는 pitch·ZCR·RMS 주입 | 0.150 / 0.510 / 0.910 — 임계값 0.3 기준으로 분리 |
| Hybrid DTW | 1000×1100 프레임(약 10초), 단어 앵커 3개 | 전역 DTW 422.5 ms 대비 23.4 ms, 앵커 40개 생성 |
| 타임맵 역변환 | 정방향 조회 후 그 값으로 역방향 재조회 | 왕복 오차 0.000000초 |
| 음소 경계 보정 | /a/→/s/ 전이가 0.60초인 합성 트랙 | 균등 분할 0.50초 → 0.60초로 이동 |
한계
강점만 적힌 문서는 코드를 읽는 사람에게 쓸모가 없으므로 그대로 적습니다.
신호처리
- Sakoe–Chiba 밴드는 프로덕션에서 무효입니다. DTW 함수가
windowSize를 받지만 애플리케이션 코드에서 아무도 넘기지 않아, 실제 경로는 각 단어 세그먼트 안에서 완전한 O(N·M) DP입니다. 밴드는 테스트 하니스에서만 쓰입니다. 실제로 비용을 제한하는 것은 100프레임 데시메이션 상한입니다. - DTW 특징 벡터가 약합니다. 프레임이 프레임별 피크 정규화 dB 크기 스펙트럼이라 모든 벡터가 큰 음수로 지배되고 코사인 거리가 0 쪽으로 압축됩니다. 단어 앵커가 정렬의 대부분을 담당하기에 동작하는 것이며, delta 특징을 포함한 MFCC가 엄밀히 더 나은 국소 비용입니다.
- 포먼트는 LPC가 아니라 피크 피킹입니다. 46 ms 윈도우와 21.5 Hz 해상도에서 고 F0 화자에 대해 찾아지는 피크는 포먼트 포락선의 피크가 아니라 개별 하모닉입니다. Levinson–Durbin과 근 탐색을 동반한 LPC가 정석적 해법입니다.
- 피치 신뢰도가 정규화되지 않았습니다. 자기상관 신뢰도가
autocorr[0]으로 나뉘어야 하는데 그 값이 기록되지 않아, 정규화된 상관계수가 아니라 사실상 에너지 게이트로 동작합니다. - 모든 DSP가 메인 스레드에서 동기 실행됩니다. Web Worker 또는 AudioWorklet이 다음 단계이고, 캡처에 쓰는
ScriptProcessorNode도 이미 deprecated입니다.
엔지니어링
- 단언이 있는 자동화 테스트가 없습니다. 하니스 스크립트는 콘솔 출력만 하고, DTW 하니스는 난수 노이즈를 주입하므로 실행마다 시간이 달라집니다. 위 표의 수치는 벤치마크가 아니라 참고값이며, 의미가 있는 것은 절대값이 아니라 배수입니다.
- 잡 큐가 in-process fire-and-forget입니다. 행을 쓴 뒤 같은 서버리스 호출 안에서 처리 함수를 await 없이 호출합니다. 클라이언트 새로고침은 견디지만 인스턴스 종료는 견디지 못합니다. 진짜 큐가 올바른 형태입니다.
- 레이트 리미터가 IP가 아니라 사용자 ID 기준입니다. 한 계정이 쓸 수 있는 양은 제한하지만 계정을 몇 개 만드는지는 막지 못합니다.
- LLM 출력에 런타임 스키마 검증이 없습니다. 응답을 파싱한 뒤 캐스팅합니다. 그 경계가 스키마 검증이 있어야 할 자리입니다.
- 음성 클로닝은 생체정보에 준하는데 현재 앱에는 동의 수집 단계가 없습니다. 본인 외 타인의 목소리를 복제하는 경로를 열려면 반드시 추가해야 합니다.
기술 스택
TypeScript Next.js Web Audio API Custom FFT Formant analysis Hybrid DTW
관련 역량
-
AI 에이전트 시스템
에이전트 루프 · 툴 계약 설계 · MCP 클라이언트 · 서버 (OAuth 2.1 · PKCE) · GraphRAG · Personalized PageRank · CSLS · 오프라인 평가 하네스 · Computer use · 승인 게이트 · 위협 모델