Skip to content

아키텍처 한눈에

HayaKoe 는 일본어 텍스트를 받아 WAV 파형을 만들어내는 엔진입니다. 내부적으로는 단일 "큰 모델" 이 한 번에 처리하는 게 아니라, 여러 단계의 파이프라인 으로 역할이 나뉘어 있습니다.

이 페이지는 입력 텍스트 하나가 음성 파형으로 나오기까지 각 단계에서 무슨 일이 일어나는지 를 순서대로 따라가며 설명합니다.

용어가 낯설다면 용어 정리 를 먼저 가볍게 훑은 뒤 돌아오는 것을 권장합니다.

전체 파이프라인

입력 텍스트 (일본어)

  ├─ 1. 문장 분할 (구두점 기준)

  ├─ 각 문장마다:
  │    ├─ 2. G2P              — 텍스트 → 음소열 + 억양
  │    ├─ 3. BERT             — 문장 단위 문맥 임베딩 (화자 공용)
  │    └─ 4. Synthesizer      — 음소 + BERT → 문장 단위 파형
  │         ├─ 4-1. Text Encoder
  │         ├─ 4-2. Duration Predictor
  │         ├─ 4-3. Flow
  │         └─ 4-4. Decoder

  ├─ 5. 문장 경계 pause       — Duration Predictor 재활용

  └─ 6. 파형 연결 → 최종 WAV

이 흐름을 아래에서 단계별로 따라가 보겠습니다.

단계별로 따라가기

1. 문장 분할

입력 텍스트는 가장 먼저 문장 단위로 쪼개집니다. 일본어 구두점 (, , ) 과 ASCII 구두점 (!, ?) 및 줄바꿈 (\n) 을 기준으로 나뉘며, 이후 모든 처리는 문장 하나씩 개별로 수행됩니다.

HayaKoe 가 문장을 한꺼번에 넣지 않고 분할하는 이유는 품질 때문 입니다. 긴 문장을 통째로 합성하면 억양이 뭉개지거나 (prosody 불안정), BERT 가 잡는 문맥이 과도하게 길어져 특정 음절이 과장되는 경향이 있습니다. 문장 단위로 끊으면 각 문장마다 안정적인 운율이 보장됩니다.

트레이드오프가 두 가지 있습니다.

첫째, 원본 모델이 통문 합성에서 자연스럽게 만들어주던 문장 사이 쉼 (pause) 이 분할로 인해 소실됩니다. 이 문제는 뒤쪽 5. 문장 경계 pause 단계에서 Duration Predictor 를 재활용해 해결합니다.

둘째, GPU 경로에서는 분할이 속도 손해로 작용 합니다. GPU 는 문장 한 개를 합성하는 데 걸리는 시간이 원래도 짧습니다. 분할을 하면 이 짧은 합성을 문장 수만큼 반복해야 하고, 문장 사이 pause 길이를 계산하는 과정도 한 번 더 추가됩니다. 그 결과 통째로 합성하는 쪽보다 총 시간이 더 듭니다.

HayaKoe 는 품질 이득이 이 비용을 정당화한다고 판단해 분할을 기본 동작으로 유지합니다. CPU 경로에서는 반대로 긴 문장을 통째로 합성하는 비용이 훨씬 커서, 분할이 속도에도 이득 으로 작용합니다.

2. G2P — 텍스트 → 음소열 + 억양

각 문장을 받은 뒤 가장 먼저 할 일은 글자를 발음으로 바꾸는 것 입니다. HayaKoe 는 일본어 G2P 를 pyopenjtalk 에 위임합니다.

G2P 가 처리하는 건 한두 가지가 아닙니다. 한자의 독법 (天気てんき), 장음 규칙, 연음 (sandhi), 억양 타입 (pitch accent) 결정 등 일본어 특유의 규칙이 모두 여기서 해결됩니다. 출력은 단순한 음소 나열이 아니라 음소 시퀀스 + 억양 정보 한 쌍입니다.

이 단계는 전적으로 CPU · Python 상에서 돌며, 모델 추론과는 무관합니다. pyopenjtalk 의 내부 사전 파일이 wheel 안에 번들링되어 있어 네트워크 접속 없이도 동작합니다 (→ OpenJTalk 사전 번들링).

3. BERT — 문맥 임베딩

G2P 와 별개로, 원문 텍스트 자체 를 DeBERTa BERT 에 넣어 문장 맥락 임베딩을 얻습니다. 각 토큰마다 "이 문장에서 이 단어가 어떤 역할인지" 를 담은 벡터가 나옵니다.

BERT 특징은 이후 Synthesizer 의 합성 품질에 직접 반영됩니다. 같은 음소열이라도 BERT 가 본 맥락에 따라 억양 · 강세가 달라집니다.

예를 들어 そうですね 라는 동일한 응답이라도, 앞에 강한 주장이 오면 확신에 찬 맞장구 톤으로, 앞에 망설임이 오면 여운이 남는 공감 톤으로 발화되는 것이 자연스럽습니다. G2P 는 음소열만 보기 때문에 두 경우를 구분할 수 없고, BERT 가 주변 문맥 정보를 압축해 Synthesizer 에 넘겨줘야 이런 차이가 합성에 반영됩니다.

이게 Bert-VITS2 계열이 원조 VITS 대비 자연스러운 발화를 만들어내는 핵심 메커니즘입니다.

중요한 구조적 특징은 BERT 가 모든 화자 공용 이라는 점입니다. BERT 는 전체 모델 파라미터의 약 84 % (~329 M, FP32 기준 약 1.2 GB) 를 차지하는 가장 덩치 큰 모듈 입니다. 반면 화자별로 달라지는 Synthesizer 는 63 M (약 251 MB) 규모입니다.

이 비대칭 덕분에 BERT 를 한 번만 로드하고 모든 화자가 공유하는 구조가 결정적입니다. 화자 N 명을 동시에 서빙해도 BERT 는 1 회만 올라가고 각 화자는 251 MB 씩만 추가됩니다. 다화자 서빙에서도 메모리 사용이 선형으로 폭증하지 않는 이유입니다.

추가로, 다문장을 처리할 때는 여러 문장을 하나의 배치로 묶어 BERT 를 1 회만 호출 합니다. GPU 에서는 커널 런치 오버헤드가 줄어 문장 수에 비례해 이득이 커지고, CPU 에서는 이득·손해가 거의 없어 백엔드 간 코드 통일을 위해 동일 경로를 유지합니다 (→ BERT GPU 유지 & 배치 추론).

4. Synthesizer — 음소 + BERT → 파형

여기가 실제 음성이 만들어지는 부분입니다. 입력은 (음소열, 억양, BERT 임베딩, 스타일 벡터), 출력은 해당 문장 분량의 파형 입니다.

이 중 스타일 벡터.load(speaker) 시점에 해당 화자의 style_vectors.npy 에서 함께 로드되는 값으로, 화자의 말투 · 톤 특성을 하나의 벡터로 압축한 표상입니다. HayaKoe 는 현재 단순화를 위해 Neutral 스타일만 사용하며 (→ 용어 정리 — Style Vector), 매 합성 호출마다 이 값이 그대로 주입됩니다.

Synthesizer 내부는 네 개의 서브 모듈로 나뉘어 있으며, 텍스트 정보가 이 순서로 흘러가며 파형으로 변환됩니다.

4-1. Text Encoder — 음소를 벡터 공간으로

Transformer 인코더 구조로, 각 음소를 192 차원 은닉 벡터 로 임베딩합니다. BERT 특징이 여기서 음소 레벨 임베딩과 결합되어, 문장 맥락이 처음으로 음소 레벨 정보에 주입됩니다.

출력 shape 은 (음소 개수, 192) 이며, 이후 두 단계의 입력으로 공유됩니다.

4-2. Duration Predictor — 각 음소를 몇 프레임 발음할지

Stochastic Duration Predictor (SDP) 가 각 음소에 대해 "몇 프레임 동안 소리낼지" 를 확률 분포에서 샘플링해 결정합니다. "안" 은 5 프레임, "녕" 은 4 프레임 같은 식입니다. 확률 샘플링이기 때문에 같은 문장이라도 호출마다 억양 · 속도가 조금씩 달라집니다.

이 단계에서 음소 시퀀스는 시간 축으로 늘어납니다. 각 음소가 자기 지속 길이만큼 반복 복사된 뒤 이어 붙여진다고 이해하면 됩니다. 결과 길이는 최종 오디오의 프레임 수와 직결됩니다.

4-3. Flow — 텍스트 임베딩 → 음성 latent

Normalizing Flow 의 역방향 변환을 거쳐, Text Encoder 가 만든 임베딩을 음성 latent 공간의 z 벡터 로 옮깁니다. 이 z 는 "어떤 소리여야 하는지" 를 담은 저차원 표상이며, 다음 Decoder 가 이걸 받아 실제 파형을 만듭니다.

Flow 는 가역 신경망이기 때문에 학습 때는 반대 방향 (정답 음성 latent → 텍스트 임베딩 공간) 으로 쓰이고, 추론 때는 그 역방향으로 탑니다.

4-4. Decoder — latent z → 파형

HiFi-GAN 계열의 Decoder 로, latent z 를 받아 시간 도메인의 실제 파형 을 생성합니다. ConvTranspose 업샘플링과 잔차 블록 (ResBlock) 으로 구성되어 있습니다.

Synthesizer 서브 모듈 중 연산량이 가장 크며, 추론 시간 대부분이 여기서 소비됩니다. CPU 경로에서 ONNX 로 내보낼 때도 이 부분이 최적화의 주 대상입니다.

이 네 단계를 모두 거치면 해당 문장 하나에 대한 44.1 kHz 파형이 완성됩니다.

5. 문장 경계 pause 예측

1 단계 에서 언급한 "문장 사이 쉼 소실" 문제를 해결하는 단계입니다.

HayaKoe 는 Duration Predictor 를 본래 용도 외에 한 번 더 재활용 합니다. 전체 원문 텍스트를 Text Encoder + Duration Predictor 까지만 통과시켜 ., !, ? 구두점 위치의 프레임 수를 얻고, 이를 초 단위 pause 시간으로 환산합니다. Flow 와 Decoder 는 건너뛰므로 추가 비용이 낮습니다.

결과적으로 고정된 80 ms 무음이 아니라 문장의 길이 · 구조에 맞춰 자연스럽게 변동하는 pause 가 생성됩니다. 상세는 문장 경계 pause — Duration Predictor 에서 다룹니다.

6. 파형 연결 → 최종 출력

마지막으로 각 문장의 파형을 순서대로 이어 붙이고, 문장과 문장 사이에 5 단계 에서 예측한 길이만큼 무음 샘플을 끼워 넣습니다.

최종 출력은 44.1 kHz · 단일 채널 float32 파형 (NumPy array) 입니다. .save() 를 호출하면 WAV 파일로 저장됩니다.

Streaming API (astream()) 를 사용하면 문장 하나가 완성될 때마다 즉시 yield 되므로, 전체 합성이 끝나기 전에 재생을 시작할 수 있습니다. 긴 텍스트에서 첫 음성까지의 지연을 크게 줄이는 효과가 있습니다 (→ 스트리밍 예제).

다음 읽을거리