VOICEVOX 엔진 API 연동 앱 개발 가이드

macOS용 송 앱의 구현 기록을 바탕으로, 공식 VOICEVOX 에디터의 내부 구성을 그대로 이식하는 것이 아니라, 외부 앱으로서 안정적으로 엔진 API를 사용하기 위한 항목을 정리합니다. Xcode/Swift 이외의 환경에서도 사용할 수 있도록 범용 방침을 먼저 쓰고, 각 항목 말미에 Xcode용 보충을 둡니다.
이 페이지는 원문 가이드를 바탕으로 내용을 생략하지 않고 문서화하고 있습니다.
1. 처음에 엔진을 URL로 등록한다

engine_manifest.json을 전제로 관리합니다. 반면 외부 클라이언트 앱에서는 우선 “실행 중인 HTTP API 서버의 URL”을 등록하는 방식으로 하는 편이 다루기 쉽습니다.
대표적인 URL은 다음과 같습니다.
| 엔진 | 예 |
|---|---|
| 공식 VOICEVOX 엔진 | http://127.0.0.1:50021 |
| Laravel판 엔진 | http://127.0.0.1:50513 |
Laravel판도
--port=50513을 지정하지 않고 기본 8000으로 실행하면 8001 이후에서 실행되지만, 포트를 지정한 경우에는 조정 기능이 비활성화됩니다.
등록 시에는 URL 문자열만 저장하지 말고, 연결 확인 겸 최소한 다음 API를 호출해 획득한 메타 정보와 함께 저장합니다.
| API | 용도 |
|---|---|
GET /engine_manifest | uuid, 엔진 이름, frame_rate, 기본 샘플링 레이트 등을 조회 |
GET /version | 표시·호환성 확인용 버전을 조회 |
GET /singers | 노래 스타일 목록을 조회 |
GET /singer_info?speaker_uuid=...&resource_format=... | 아이콘이나 추가 정보를 조회 |
engineId와 styleId를 저장하는 것입니다. URL은 사용자 환경마다 달라지지만, engineId는 /engine_manifest의 uuid에 대응하며, .vvproj의 singer.engineId에도 맞추기 쉽습니다.
Xcode용 보충: 송 앱에서는 RegisteredEngine에 baseURL, engineId, name, frameRate, defaultSamplingRate, version, singers를 보관하고, UserDefaults에 저장합니다. URL은 끝의 슬래시, 쿼리, 프래그먼트를 정규화한 뒤 사용하면 http://127.0.0.1:50021/과 http://127.0.0.1:50021을 동일하게 다룰 수 있습니다. macOS 앱에서 HTTP의 로컬 연결을 사용하는 경우에는 샌드박스나 App Transport Security 설정도 확인합니다.
2. 공식 앱과 같은 “엔진 시작”까지 떠안지 않는다
외부 앱에서 처음부터 공식 에디터와 같은 엔진 시작 관리를 구현하려고 하면, OS별 프로세스 시작, 동봉 바이너리, 포트 충돌, 업데이트, 로그 관리까지 다뤄야 합니다. 특히 공식 엔진 이외의 구현을 사용하는 경우, 엔진 실행 파일 위치를 지정해 시작하는 방식은 궁합이 좋지 않습니다. 우선은 다음 운영으로 고정하는 것이 안전합니다.- 사용자가 엔진을 먼저 시작합니다.
- 앱에서 URL을 입력합니다.
- 앱이 메타 정보를 가져와 등록합니다.
- 렌더링 시에는 등록된
engineId에서 URL로 해결합니다.
TextField에 URL, 등록 / 재연결 버튼, 공식/Laravel판 프리셋 버튼, 등록된 엔진 목록을 두면 운영하기 쉽습니다. 비동기 연결 중에는 ProgressView를 표시하고, 연결 오류는 LocalizedError 문구를 그대로 표시하기보다는 사용자용 설명을 앞에 두면 좋습니다.
3. 가수·스타일은 엔진 등록 후에 선택하게 한다
VOICEVOX API에서는 최종적으로 음성 생성에 사용하는 목소리가speaker 쿼리 파라미터에 전달하는 style ID로 결정됩니다. 송에서는 트랙마다 가수 스타일을 갖기 때문에 UI에서는 등록된 엔진에서 singers를 펼쳐 가수명·스타일명·style ID를 선택지로 만듭니다.
저장하는 값은 다음 두 가지면 충분합니다.
.vvproj 호환 송 데이터와 맞추기 쉽고 멀티엔진에도 대응하기 쉽습니다. 프로젝트를 열었을 때 해당 engineId가 미등록이라면 “미등록 엔진”으로 표시하고 URL 등록을 유도합니다.
Xcode용 보충: Swift에서는 Singer(engineId: String, styleId: Int)를 트랙에 갖게 하고, 표시용으로는 RegisteredEngine.singers에서 SingerStyleOption과 같은 ViewModel용 배열로 평탄화하면 좋습니다. 가수 할당을 변경했다면 렌더링된 음성은 무효화합니다.
4. .vvproj 호환을 고려한다면 tick 기준의 송 모델로 한다
VOICEVOX 에디터의 송 데이터는 음표나 템포를 tick 기준으로 갖습니다. 엔진 API 자체는 최종적으로 프레임 길이를 필요로 하지만, 앱 내부의 편집 모델은 tick 기준으로 두는 편이 피아노 롤, 템포 변경, 박자, Undo/Redo, .vvproj 읽기/쓰기에 유리합니다.
최소 구조는 다음과 같습니다.
| 요소 | 주요 필드 |
|---|---|
| Song | tpqn, tempos, timeSignatures, tracks, trackOrder |
| Track | name, singer, notes, gain, pan, solo, mute |
| Note | id, position, duration, noteNumber, lyric |
| Tempo | position, bpm |
| TimeSignature | measureNumber, beats, beatType |
frame_rate에 맞춰 frame_length를 계산합니다.
Xcode용 보충: Codable로 .vvproj 스타일 JSON을 읽고 쓸 때는 tracks를 ID를 키로 하는 Dictionary로, trackOrder를 표시 순 배열로 나눕니다. 저장 전에 trackOrder의 중복, 존재하지 않는 ID, 정렬되지 않은 트랙을 검증하면 읽어들인 후의 UI 붕괴를 방지할 수 있습니다.
5. 송 합성은 4단계 API로 구현한다
송 렌더링은 토크의/audio_query → /synthesis보다 단계가 많습니다. 기본 파이프라인은 다음 4단계입니다.
/sing_frame_audio_query, /sing_frame_f0, /sing_frame_volume에서는 singing teacher의 style ID인 6000을 사용하고, 마지막 /frame_synthesis에서만 실제로 선택된 가수의 styleId를 사용합니다. 이 값은 공식 엔진에서도 Laravel판 엔진에서도 같은 전제로 다룰 수 있습니다.
각 API 요청에서는 Score와 FrameAudioQuery의 대응이 무너지지 않는 것이 중요합니다. Score.notes[].frame_length는 엔진의 frame_rate에서 산출하고, 무음은 key: null, lyric: ""으로 표현합니다.
Xcode용 보충: Swift에서는 VoicevoxEngineSongAPI와 같은 protocol을 만들고 구현체를 URLSession 클라이언트로 하면 테스트하기 쉽습니다. JSON을 반환하는 엔드포인트에서는 Content-Type / Accept를 application/json으로 하고, /frame_synthesis의 응답은 WAV 바이너리 Data로 다룹니다. HTTP 2xx 이외에는 상태 코드와 응답 본문을 포함한 오류로 처리합니다.
6. 프레이즈 단위로 분할하여 캐시한다
송 전체를 매번 한 번에 합성하면, 약간 편집한 것만으로도 모든 트랙이 재생성되어 UI 응답성이 나빠집니다. 공식 에디터와 마찬가지로, 연속된 노트 그룹을 프레이즈로 나누고 쉼표로 분할하는 설계가 다루기 쉽습니다. 프레이즈마다 다음 단계를 캐시합니다.| 캐시 | 입력에 포함할 것 |
|---|---|
| AudioQuery | 엔진 ID, frame rate, 템포, Score, 음역 조정 |
| F0 | AudioQuery, 피치 편집, 프레이즈 위치 |
| Volume | AudioQuery, F0, 볼륨 생성에 영향을 주는 값 |
| Voice/WAV | 합성용 Query, 최종 style ID |
actor에 렌더링 캐시를 갖게 하면 Swift Concurrency 상에서 상태 관리하기 쉽습니다. 새로운 렌더링이 시작되면 세대 번호를 진행시키고, 오래된 API 응답이 돌아와도 채택하지 않습니다. Task.checkCancellation()과 세대 체크를 결합하면 취소와 재렌더링 양쪽을 다룰 수 있습니다.
7. 프레임 레이트와 쉼표를 엔진 메타 정보에서 결정한다
ScoreNote.frame_length는 초 수와 frame_rate에서 계산합니다. frame_rate를 고정값으로 임베드하지 말고 등록 시 /engine_manifest에서 획득해 저장합니다.
송 합성에서는 프레이즈 앞뒤에 쉼표를 넣습니다. 앞쪽 쉼표가 너무 짧으면 음소 생성이 불안정해지는 경우가 있으므로, 구현에서는 “실제 쉼표”, “4분음표”, “최저 초 수로부터 역산한 tick”의 균형을 잡습니다. 뒤쪽에도 짧은 무음을 추가하고, 필요하면 마지막 무음 구간을 페이드아웃합니다.
또, 프레임 길이는 반올림 결과가 0 이하가 되지 않도록 보정합니다. 짧은 노트나 템포 변경 직후에는 반올림 오차로 0 프레임이 발생하기 쉽습니다.
Xcode용 보충: tickToSecond / secondToTick을 순수 함수로 만들어 테스트해 두면 피아노 롤, 재생 헤드, 렌더링, 동영상 출력에서 같은 변환을 사용할 수 있습니다. frameLength = Int(round(seconds * frameRate))의 결과는 인접 노트에 차분을 넘기는 등으로 최저 1프레임을 보장합니다.
8. 파라미터 편집은 “생성 전”과 “합성 전” 어디에 적용되는지를 나눈다
송에서는 음역·성량·피치·볼륨·음소 타이밍 편집이 각각 다른 단계에 영향을 줍니다.| 편집 | 반영 타이밍 |
|---|---|
keyRangeAdjustment | Score 생성 시의 키, F0 생성 후의 피치 시프트 |
volumeRangeAdjustment | /frame_synthesis 전의 볼륨 배열 |
| 피치 편집 | /sing_frame_f0 후의 F0 배열 |
| 볼륨 편집 | /sing_frame_volume 후의 volume 배열 |
| 음소 타이밍 편집 | FrameAudioQuery.phonemes |
SongParameterEditApplicator와 같은 순수 로직에 몰아 넣으면 테스트하기 쉽습니다. 배열의 범위 밖 접근이나 음의 볼륨은 명시적으로 보정·오류화합니다.
9. 재생·믹스·내보내기는 같은 트랙 판정을 사용한다
렌더링 후에는 프레이즈별 WAV를 타임라인 상에 배치해 재생합니다. 멀티트랙 대응에서는 통상 재생, 전체 WAV 내보내기, stem 내보내기에서 solo/mute/gain/pan의 판정이 어긋나면 사용자에게 예측하기 어려운 거동이 됩니다. 공통 규칙으로서 solo 트랙이 하나라도 있는 경우에는 solo 트랙만 내보내고, solo가 없는 경우에는 mute되지 않은 트랙을 내보냅니다. stem 내보내기에서는 대상 트랙을 단독으로 출력하고 통상 solo/mute 상태와는 분리해서 생각합니다. Xcode용 보충: macOS에서는AVAudioEngine, AVAudioPlayerNode, AVAudioMixerNode로 재생 그래프를 만들 수 있습니다. 내보내기는 통상 재생과는 별도로 오프라인 렌더링용 경로를 준비하고, WAV 인코딩을 공통화하면 좋습니다. 재생 중에 편집이 들어와 렌더링된 음성이 무효화되면 재생을 중지하고 상태를 명시합니다.
10. UI는 “미등록·미할당·미렌더링”을 구분한다
VOICEVOX 엔진 API 연동 앱에서는 실패 이유가 몇 가지로 나뉩니다.| 상태 | 표시할 안내 |
|---|---|
| 엔진 미등록 | 엔진 연결 화면에서 URL을 등록 |
| 엔진 미실행 | 엔진을 실행하고 재연결 |
프로젝트의 engineId가 미등록 | 대응하는 엔진 URL을 추가 등록 |
| 트랙에 가수 미할당 | 등록된 가수 스타일을 선택 |
| 편집 후 음성이 오래됨 | 재렌더링 필요 |
| 렌더링 중 | 취소 가능하게 함 |
renderingState를 idle, rendering, rendered, stale, failed와 같은 enum으로 하면 버튼의 disabled 제어와 상태 표시를 일관되게 하기 쉽습니다. 오류 메시지는 상태 바뿐만 아니라 얼럿에도 표시할 수 있게 해 둡니다.
11. 멀티엔진에서는 “URL”과 “engineId”의 수명을 나눈다
멀티엔진 대응에서 혼란스러운 것은 URL과engineId의 역할이 다르다는 것입니다.
| 값 | 수명 | 사용법 |
|---|---|---|
| URL | 사용자 환경마다 변화 | API 호출 대상으로 사용 |
engineId | 엔진 구현·모델 측의 식별자 | 프로젝트 내의 참조로 사용 |
styleId | 엔진 내의 스타일 식별자 | speaker 파라미터로 사용 |
engineId와 styleId를 저장합니다. 앱 설정 측에 engineId -> URL 대응을 갖게 합니다. 이렇게 하면 다른 사람에게서 받은 프로젝트라도, 자기 환경에서 같은 엔진을 URL 등록하면 렌더링할 수 있습니다.
Xcode용 보충: Dictionary(uniqueKeysWithValues: registeredEngines.map { ($0.engineID, $0) }) 처럼 렌더링 전에 engineId에서 RegisteredEngine을 해결합니다. 찾지 못한 경우에는 HTTP 요청을 보내지 말고, 사전에 “가수의 엔진이 등록되어 있지 않습니다”로 실패시킵니다.
12. 테스트는 HTTP 클라이언트·모델 변환·렌더링 계획으로 나눈다
VOICEVOX 엔진 본체를 항상 실행해서 테스트하는 것은 무겁기 때문에, 일반적인 자동 테스트에서는 HTTP를 목킹하고 앱 측의 요청 생성과 상태 전이를 확인합니다. 우선적으로 테스트하고 싶은 항목은 다음과 같습니다.| 대상 | 확인할 것 |
|---|---|
| URL 정규화 | 끝의 슬래시나 경로 있는 URL이 예상대로 처리됨 |
| 엔진 등록 | /engine_manifest, /version, /singers, /singer_info를 호출해 저장용 모델을 만들 수 있음 |
.vvproj I/O | trackOrder와 tracks의 정합성, 오래된/미래 버전의 취급 |
| tick/second 변환 | 템포 변경을 넘나드는 변환이 왕복 가능 |
| Score 생성 | 쉼표, frame_length, 기본 가사, 음역 조정 |
| 렌더링 계획 | 미등록 엔진, 가수 미설정, engineId 불일치를 API 호출 전에 검출 |
| 캐시 | 같은 입력으로 재사용되고, 편집 후에 무효화됨 |
URLProtocol을 교체한 URLSessionConfiguration.ephemeral을 사용하면 실제 HTTP 서버 없이 URLSession 클라이언트를 테스트할 수 있습니다. 렌더러는 protocol을 통해 API를 주입하고, actor의 캐시 상태는 스냅샷을 반환하는 메서드를 준비하면 검증하기 쉽습니다.
구현 순서 추천
최소 구성부터 시작한다면 다음 순서가 좋습니다.
처음부터 공식 에디터의 모든 기능을 쫓기보다는 URL 등록·가수 할당·4단계 API·재렌더링 상태를 먼저 굳혀 두면, 공식 엔진에도 Laravel판 엔진에도 맞추기 쉽습니다.