🗣️

VOICEVOX — 무료 AI 음성 합성 엔진의 내부 구조

일본어 텍스트 → 음소 → 피치 → 파형, 3단계 분리형 추론이 사용자 편집을 가능하게 하는 방법

VOICEVOX는 유튜브 실황/해설 영상에서 가장 많이 쓰이는 무료 AI 음성 합성 소프트웨어다. ずんだもん(즌다몬), 四国めたん 같은 캐릭터 보이스를 생성한다.

근데 이게 내부적으로 어떻게 돌아가는지 아는 사람은 많지 않다.

3단계 분리형 아키텍처

VOICEVOX는 VITS 같은 end-to-end 모델이 아니다. 3개의 독립 DNN 모델을 순차 실행하는 cascade 방식이다.

1단계 — yukarin_s: 음소 ID 배열을 받아서 각 음소의 지속 시간을 예측한다. "こんにちは"의 k, o, N, n, i, ch, i, w, a 각각이 몇 초인지.

2단계 — yukarin_sa: 모음/자음 ID + 악센트 위치를 받아서 모라별 f0(음높이)를 예측한다. 일본어의 고저 악센트를 여기서 결정한다.

3단계 — decode: 음소의 onehot 벡터(45종, 프레임 단위)와 f0를 받아서 24kHz 오디오 파형을 생성한다. vocoder 역할.

이렇게 분리한 이유가 있다. 1~2단계 결과를 JSON(AudioQuery)으로 사용자에게 돌려주고, 사용자가 피치와 길이를 직접 조정한 뒤 3단계를 실행할 수 있다. end-to-end 모델에서는 불가능한 세밀한 제어가 가능해진다.

텍스트 → 음소 변환

OpenJTalk(형태소 분석기)가 일본어 텍스트를 음소+악센트 정보로 분해한다. VOICEVOX는 자체 fork한 pyopenjtalk을 사용한다.

흐름: 일본어 텍스트 → MeCab 형태소 분석 → NJD(Natural Language Processing) → HTS 라벨 생성 → 정규식 파싱 → AccentPhrase 리스트

음소는 45종이다. pau(포즈), cl(촉음 っ), N(발음 ん), 대문자 모음(A, I, U, E, O)은 무성화 모음.

3계층 소프트웨어 구조

VOICEVOX 에디터(Electron/TypeScript) → Engine(Python/FastAPI) → Core(Rust/ONNX Runtime)

Engine이 텍스트 전처리, API 서버, 파라미터 편집을 담당하고, 실제 DNN 추론은 Core가 ONNX Runtime으로 실행한다.

Engine → Core: ctypes FFI가 구체적으로 뭘 하는가

Core는 Rust로 작성된 공유 라이브러리(.dll/.so/.dylib)다. Python Engine은 이걸 ctypes로 직접 호출한다. ONNX Runtime은 Core DLL 안에 내장되어 있어서 Python 쪽에서는 ONNX 세션을 전혀 다루지 않는다.

DLL 로딩 순서: 먼저 load_runtime_lib()가 ONNX Runtime DLL을 사전 로드하고, load_core()가 Core DLL을 로드한다. GPU 타입에 따라 다른 바이너리를 선택한다 — CUDA GPU → DirectML GPU → CPU 순으로 폴백.

함수 시그니처 바인딩: Core의 C 함수(yukarin_s_forward, decode_forward 등)의 인자 타입을 ctypes로 선언한다. argtypes=(c_int, POINTER(c_long), POINTER(c_float)) 식으로. 모든 함수는 c_bool을 반환하고, 실패하면 last_error_message()로 에러를 가져온다.

numpy ↔ C 포인터 변환: 이게 핵심이다. numpy 배열의 데이터 포인터를 C 포인터로 직접 넘긴다. phoneme_list.ctypes.data_as(POINTER(c_long)) — 복사가 아니라 같은 메모리를 C 함수가 읽는다. 출력 버퍼도 numpy 배열을 미리 할당해서 C 함수가 거기에 직접 쓴다. decode의 경우 np.empty((length * 256,), dtype=np.float32) — 프레임 수 × 256이 오디오 샘플 수다.

스레드 안전: CoreAdapter가 threading.Lock()으로 모든 추론 호출을 감싼다. ONNX Runtime 세션이 스레드 세이프하지 않기 때문. with self.mutex: 안에서만 Core 함수를 호출한다.

전후 무음 패딩: yukarin_s와 yukarin_sa는 입력 배열 전후에 무음(0)을 추가한다 — np.r_[0, phoneme_list, 0]. 추론 후 결과에서 양끝을 잘라낸다. Core 사양에서 요구하는 규칙.

Core 내부 (Rust)

Core의 Rust 코드에서 C ABI 함수(#[unsafe(no_mangle)] pub extern "C" fn yukarin_s_forward(...))가 C 포인터를 받아서 unsafe { std::slice::from_raw_parts } 또는 ndarray::ArrayView::from_shape_ptr로 Rust 배열로 변환한다. 이걸 ONNX Runtime 세션에 넣어서 추론하고, 결과를 다시 C 포인터로 복사한다.

ONNX Runtime 통합은 ort crate(pykeio/ort)를 사용한다. libloading::Library::new()로 ONNX Runtime SO/DLL을 동적 로드하고, GPU 선택은 CUDAExecutionProvider 또는 DirectMLExecutionProvider를 세션 빌더에 등록하는 방식. 모델은 .vvm 파일(ZIP 아카이브) 안에 .onnx 형식으로 들어있다.

GPU 가속

ONNX Runtime이 GPU 추론을 담당한다. CPU 대비 GPU(A4000)에서 약 330배 빠르다. CUDA 또는 DirectML 백엔드를 지원한다. VOICEVOX는 자체 커스텀 빌드한 ONNX Runtime을 사용한다.

유튜브 크리에이터에게 중요한 이유

무료다. 상용 이용 가능하다(크레딧 필수). YMM4(ゆっくりムービーメーカー4)와 HTTP API로 바로 연동된다. 캐릭터별 감정 스타일(あまあま, ツンツン, ささやき 등)을 바꿀 수 있다. 기존 ゆっくり 보이스의 수익화 불확실성 문제를 해결한다.

Docker로 HTTP 서버 실행

VOICEVOX Engine은 FastAPI 기반 HTTP 서버다. 로컬에 설치하지 않고 Docker로 바로 띄울 수 있다.

docker pull voicevox/voicevox_engine:cpu-ubuntu20.04-latest
docker run --rm -p 50021:50021 voicevox/voicevox_engine:cpu-ubuntu20.04-latest

GPU 버전도 있다. nvidia-ubuntu20.04-latest 태그를 쓰면 되고 --gpus all 옵션을 붙인다.

서버가 뜨면 http://localhost:50021/docs에서 Swagger UI로 전체 API를 확인할 수 있다. 핵심 API는 두 개다:

  1. POST /audio_query?text=こんにちは&speaker=3 — 텍스트를 AudioQuery(JSON)로 변환
  2. POST /synthesis?speaker=3 — AudioQuery를 받아서 WAV 오디오를 반환

curl로 한 줄이면 음성을 생성할 수 있다. GUI 에디터 없이 스크립트나 서버 사이드에서 TTS를 자동화할 때 이 방식이 편하다. YMM4도 내부적으로 이 HTTP API를 호출한다.

3단계 추론 파이프라인

단계 모델 입력 출력
1 yukarin_s 음소 ID 배열 + style_id 음소별 지속 시간(초)
2 yukarin_sa 모음/자음 ID + 악센트 위치 + style_id 모라별 f0(음높이, Hz)
3 decode 음소 onehot(45종, 프레임 단위) + f0 24kHz 오디오 파형

프레임 레이트: 93.75fps (24000Hz / hop_size 256). 1프레임 ≈ 10.67ms

Engine → Core: ctypes FFI 상세

Python Engine은 Rust Core의 C ABI 함수를 ctypes로 직접 호출한다. ONNX Runtime은 Core DLL 안에 캡슐화되어 있어서 Python 쪽은 세션을 전혀 모른다.

호출 흐름 (yukarin_s_forward 예시)

Python phoneme_ids = np.array([0, 14, 31, ...], dtype=np.int64)
Python phoneme_ids = np.r_[0, phoneme_ids, 0] # 전후 무음 패딩
ctypes phoneme_ids.ctypes.data_as(POINTER(c_long)) # 복사 없이 포인터 전달
ctypes output = np.zeros((length,), dtype=np.float32) # 출력 버퍼 사전 할당
C ABI core.yukarin_s_forward(len, phoneme_ptr, style_ptr, output_ptr)
Rust unsafe { slice::from_raw_parts(phoneme_ptr, len) } → ndarray → ONNX Session.run()
Rust output.as_ptr().copy_to_nonoverlapping(output_ptr, len) # 결과를 Python 버퍼에 직접 쓰기
Python output[1:-1] # 패딩 제거 → 음소별 길이 배열

핵심 패턴

  • 제로카피 — numpy 배열의 메모리를 C 함수가 직접 읽고 쓴다. 복사 오버헤드 없음
  • threading.Lock() — 모든 Core 호출을 직렬화. ONNX Runtime 세션이 스레드 세이프하지 않기 때문
  • lazy loading — style_id별로 모델을 처음 사용할 때 로드. 시작 시간 단축
  • 에러 전파 — C 함수가 false 반환 → last_error_message()로 Rust 에러를 Python 예외로 변환

Core 내부: Rust → ONNX Runtime

1. DLL 로드 libloading::Library::new()로 ONNX Runtime SO/DLL을 동적 로드 → OrtGetApiBase 심볼 획득
2. GPU 선택 세션 빌더에 CUDAExecutionProvider 또는 DirectMLExecutionProvider 등록. Auto 모드에서는 순차 테스트
3. 모델 로드 .vvm 파일(ZIP) → .onnx 바이트 추출 → builder.commit_from_memory(onnx)로 세션 생성
4. 추론 ndarray → ort::value::Value::from_array()session.run(inputs) → 결과를 as_standard_layout()로 C-contiguous 보장 후 포인터 복사

ONNX 세션은 async_lock::Mutex로 보호. C API는 blocking, Python/Java API는 async 경로

왜 end-to-end가 아닌 분리형인가

VITS 같은 end-to-end 모델은 텍스트를 넣으면 바로 음성이 나온다. 편하지만 중간 결과를 건드릴 수 없다. \"이 단어의 피치를 좀 올리고 싶다\"가 불가능하다.

VOICEVOX는 1~2단계 결과를 AudioQuery(JSON)로 사용자에게 돌려준다. 사용자가 에디터 UI에서 피치 곡선을 직접 그리고, 음소 길이를 조정한 뒤, 3단계(vocoder)만 다시 실행한다. 이 \"편집 가능한 중간 표현\"이 VOICEVOX의 핵심 설계 철학이다.

소프트웨어 3계층 구조

Editor Electron/TypeScript — GUI 에디터. 피치 곡선 편집, 캐릭터 선택, 음성 재생
↓ HTTP (localhost:50021)
Engine Python/FastAPI — 텍스트 전처리(OpenJTalk), API 서버, 파라미터 편집, 모핑, 후처리
↓ ctypes FFI (C ABI)
Core Rust — ONNX Runtime으로 DNN 추론 실행. yukarin_s / yukarin_sa / decode 모델 구동
↓ ONNX Runtime
GPU/CPU CUDA (Nvidia) / DirectML (Nvidia+AMD, Windows) / CPU fallback

음소 시스템 (45종)

일본어의 모든 발음을 45개 음소로 표현한다.

pau(포즈) A I U E O(무성화 모음) a i u e o(유성 모음) N(ん) cl(っ) b by ch d dy f g gw gy h hy j k kw ky m my n ny p py r ry s sh t ts ty v w y z

대문자 모음(A,I,U,E,O)은 무성화 — \"です\"의 u가 실제로는 안 들리는 현상

GPU vs CPU 성능

환경 합성 속도 비고
CPU (저가 VPS) ~0.1회/초 100회 합성에 ~100초
GPU (A4000) ~33회/초 100회 합성에 ~3초 (330배)

NVIDIA GPU, VRAM 3GB 이상 필요. VOICEVOX 자체 빌드 ONNX Runtime 사용

AudioQuery — 편집 가능한 중간 표현

{
  "accent_phrases": [{
    "moras": [
      {"text":"コ","consonant":"k","consonant_length":0.06,
       "vowel":"o","vowel_length":0.12,"pitch":3.45},
      {"text":"ン","consonant":null,"consonant_length":null,
       "vowel":"N","vowel_length":0.10,"pitch":3.80}
    ],
    "accent": 1,
    "is_interrogative": false
  }],
  "speedScale": 1.0,
  "pitchScale": 0.0,
  "intonationScale": 1.0,
  "volumeScale": 1.0,
  "outputSamplingRate": 24000
}

모라 단위로 자음/모음/길이/피치를 직접 편집 → /synthesis API로 파형 생성

개발자 배경

ヒホ(Hiho/Hiroshiba) — 드완고 미디어 빌리지 소속 ML 엔지니어. AI 음성 변환 라이브러리 \"yukarin\" 개발자. 2021년 8월 첫 릴리즈. 목표: 무료 캐릭터 음성을 만들고 싶은 사람과 사용하고 싶은 사람을 연결하는 것.

실전 순서

1

일본어 텍스트 입력 → OpenJTalk가 형태소 분석 + 악센트 구(AccentPhrase) 생성

2

yukarin_s 모델이 음소별 지속 시간(길이)을 예측

3

yukarin_sa 모델이 모라별 f0(음높이/피치)를 예측

4

AudioQuery(JSON)를 사용자에게 반환 — 피치, 속도, 억양을 편집 가능

5

decode 모델(vocoder)이 편집된 파라미터로 24kHz WAV 파형을 생성

장점

  • 완전 무료 + 상용 이용 가능 (크레딧 표기 필수)
  • 25종+ 캐릭터 × 8종 감정 스타일 — 다양한 보이스 선택지
  • AudioQuery로 피치/속도/억양을 수동 미세 조정 가능
  • GPU 가속 시 CPU 대비 ~330배 고속 (ONNX Runtime)

단점

  • 일본어 전용 — 영어/한국어 음성 합성 불가
  • GPU(CUDA) 없이 CPU만으로는 합성 속도가 느림
  • DirectML 버전에서 멀티 GPU 환경(Optimus 등) 문제 보고
  • 캐릭터별 이용 규약이 제각각 — 사전 확인 필수

사용 사례

ゆっくり 실황 대체 — 수익화 문제 없는 캐릭터 보이스 YMM4 + VOICEVOX 조합으로 완전 무료 유튜브 영상 제작 캐릭터 감정 스타일(달콤/츤데레/속삭임 등)로 다양한 연출 REST API(localhost:50021)로 자동화 스크립트/봇 연동