요즘 아이들과 함께 작은 던전 크롤러 게임을 만들며 게임 개발을 조금씩 배우는 중입니다. 기술이 하루가 다르게 발전하는 시대인 만큼 내레이터와 음성 더빙도 넣고 싶었습니다. 다만 어디서나 들을 수 있는 흔한 목소리처럼 들리는 건 원치 않았습니다.
다만 어디서나 들을 수 있는 흔한 AI 목소리처럼 들리는 건 원치 않아서, 어떤 선택지가 있는지 찾아봤습니다. 이것저것 살펴본 끝에 커스텀 목소리에 voice embedding을 쓰는 Qwen3-TTS로 정했습니다.
약간의 아이디어만 더하면 이 커스텀 목소리로 처음 생각한 것보다 훨씬 많은 일을 할 수 있습니다. 관심 있는 분들을 위해 저희가 알게 된 내용을 예제와 함께 정리했습니다. 이 글을 에이전트에게 읽혀도 알아서 활용할 수 있을 겁니다.
Qwen3가 유일한 TTS 모델도 아니고, 처음부터 Qwen3를 고른 것도 아닙니다. 처음에는 Kokoro를 썼습니다. 하지만 Qwen3는 voice cloning을 지원해서 목소리를 조금 더 독특하게 만들 수 있겠다고 생각했습니다.
다만 실제로 써 보면 조금 까다롭습니다. Qwen3는 세 종류의 모델을 제공합니다.
1.7B-Base(0.6B 포함): 몇 초 분량의 참조 오디오만으로 목소리를 복제합니다.1.7B-CustomVoice: 기본 제공 화자 9명과 instruction 기능을 갖췄습니다.1.7B-VoiceDesign: 텍스트 설명만으로 새로운 목소리를 만들어 냅니다.Base 모델은 컨텍스트 방식이나 speaker embedding만으로 목소리를 복제할 수 있습니다. 하지만 복제한 목소리에 instruction을 적용하는 방법은 문서에 나와 있지 않습니다. 즉 CustomVoice나 VoiceDesign 모델과 달리, 복제한 Base 모델에는 "슬프게 말해" 또는 "속삭여" 같은 지시를 할 수 없습니다.
현재 공식적으로 커스텀 목소리를 만드는 방법은 두 가지입니다. 한 명의 화자로 Base를 fine-tuning하거나, VoiceDesign으로 클립을 만든 뒤 Base로 복제하는 것입니다. 나쁘지 않지만 instruction은 쓸 수 없게 됩니다.
이 글에서는 PyAI Conf 발표에서 제 목소리 28초 분량을 사용했습니다. 문장이 온전히 시작하고 끝나도록 두 번의 휴지 구간 사이를 잘라 냈고, Whisper로 받아 적었습니다. 받아 쓴 텍스트는 사실 baking에만 필요하지만, 기존 목소리와 비교할 때 원본 녹음과 같은 문장을 읽히면 편합니다.
컨퍼런스 발표 녹음은 이상적인 소스는 아니지만 충분히 쓸 만합니다. 그럴듯한 목소리를 만드는 데 아주 깨끗하거나 긴 녹음이 필요하지 않다는 점을 다시 일깨워 주기도 합니다. 특히 음질이 낮은 전화 통화로 들려준다면 할머니를 속이는 것도 어렵지 않을 겁니다.
가장 먼저 해 볼 수 있는 일은 이 녹음을 다른 목소리와 섞는 것입니다. 예를 들어 VoiceDesign으로 설명 문장에서 목소리를 만들 수 있습니다. 이 글에서는 Qwen의 공식 Python 패키지 qwen-tts(pip install qwen-tts, 저는 0.1.1 버전을 썼습니다)로 아래처럼 목소리를 만들었고, 이후 코드도 모두 이 패키지를 씁니다.
import torch
from qwen_tts import Qwen3TTSModel
kw = dict(device_map="mps", dtype=torch.bfloat16, attn_implementation="sdpa")
design = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign", **kw)
wavs, sr = design.generate_voice_design(
text=open("voices/transcript.txt").read(), language="English",
instruct="A man in his forties with a warm, natural, medium-low voice, speaking "
"clearly and calmly at an easy conversational pace, like someone reading "
"aloud to a friend.",
)
두 목소리가 각각 어떻게 들리는지, 그리고 섞으면 어떻게 달라지는지 들어 보세요.
Base가 embedding만으로 복제할 때 녹음에서 가져오는 것은 speaker embedding입니다. 학습된 speaker encoder가 출력하는 2048개의 숫자로, 같은 목소리는 비슷한 숫자를, 다른 목소리는 다른 숫자를 갖습니다. 덧붙이면 녹음에서 화자를 구분하는 데도 쓸 수 있습니다. 완벽하진 않지만, 트랙이 하나뿐인 녹음에서 기본적인 분할 작업만 거치면 두 화자를 꽤 잘 구분합니다(자세한 내용은 아래에서 다룹니다).
embedding은 그저 벡터이므로 산술 연산이 가능합니다. 예를 들어 두 embedding의 가중 평균은 두 목소리의 중간쯤에 있는 화자가 됩니다.
base = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-Base", **kw)
def fingerprint(path):
prompt = base.create_voice_clone_prompt(ref_audio=path, x_vector_only_mode=True)
return prompt[0].ref_spk_embedding
def blend(a, b, t):
a, b = a.float(), b.float()
mixed = (1 - t) * a + t * b
return (mixed / mixed.norm() * ((1 - t) * a.norm() + t * b.norm())).to(torch.bfloat16)
armin, designed = fingerprint("voices/armin.flac"), fingerprint("voices/designed.flac")
같은 문장을 11단계의 블렌딩으로 만든 결과입니다.
제 목소리와는 금세 멀어지지만 특징은 어느 정도 남습니다. 어느 쪽이든 결과적으로 세상에 하나뿐인 목소리가 만들어집니다. 두 목소리의 스펙트로그램이 비슷할 때(예: 비슷한 "녹음 환경") 결과가 더 좋았습니다. 이 녹음에서는 라펠 마이크로 잡은 제 목소리가 VoiceDesign으로 만든 목소리보다 다소 먹먹하게 들립니다.
마음에 드는 목소리가 나왔다면 새 녹음으로 baking해서 참조 오디오로 쓸 수 있습니다.
import soundfile as sf
mixed = blend(armin, designed, 0.5)
audio, rate = say(open("voices/transcript.txt").read(), mixed) # say() is shown further down
sf.write("voices/final.flac", audio, rate)
final = fingerprint("voices/final.flac")
baking한 목소리가 블렌딩 결과와 똑같지는 않지만, 참조 파일 하나로 관리할 수 있어 다루기 편합니다.
그렇다면 이 embedding을 화자 인식에 쓰면 얼마나 잘 작동할까요? Ben과 제가 녹음하는 팟캐스트 State of Agentic Coding의 한 에피소드를 1초 간격의 2초 구간으로 잘라, 각 구간을 컨퍼런스에서 추출한 제 embedding과 비교해 봤습니다.
의외지만 한편으론 당연하게도, 컨퍼런스 녹음 클립을 참조로 쓰면 Qwen3로는 전혀 구분이 되지 않았습니다. 반면 에피소드 초반에 제가 자기소개를 하는 20초를 참조로 쓰면 나머지 구간의 98%를 맞혔습니다.
한편 화자 검증용으로 학습된 모델은 컨퍼런스 클립만으로도 제 목소리를 찾아냅니다. 예를 들어 ECAPA는 구간의 97%에서 컨퍼런스 클립 속 제 목소리를 찾아냈습니다.
다음은 이 팟캐스트 20분 분량을 각 방식으로 화자별 분류한 결과입니다.
embedding은 voice cloning에 매우 유용합니다. 그런데 여기에 instruction을 주려면 어떻게 해야 할까요? Base와 CustomVoice는 화자를 같은 방식으로 다룹니다. talker의 입력 시퀀스에 벡터 하나를 넣는 것입니다. CustomVoice에서 이 벡터는 embedding 테이블의 한 행이고, Base에서는 Base에만 있는 speaker encoder가 내놓는 x-vector입니다. 생성 코드는 둘 중 어느 쪽이든 받을 수 있고(voice_clone_prompt는 generate의 일반 인자입니다), 두 모델의 transformer 가중치도 거의 같습니다. 그러니 Base로 embedding을 뽑은 뒤, CustomVoice의 기본 화자 대신 그 embedding을 넘기면 됩니다.
cv = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", **kw)
def say(text, xvec, instruct=None, language="English"):
ids = cv._tokenize_texts([cv._build_assistant_text(text)])
told = [cv._tokenize_texts([cv._build_instruct_text(instruct)])[0]
if instruct else None]
voice = {"ref_code": [None], "ref_spk_embedding": [xvec],
"x_vector_only_mode": [True], "icl_mode": [False]}
codes, _ = cv.model.generate(
input_ids=ids, instruct_ids=told, voice_clone_prompt=voice,
languages=[language], non_streaming_mode=True,
**cv._merge_generate_kwargs(temperature=0.7, subtalker_temperature=0.7),
)
wavs, rate = cv.model.speech_tokenizer.decode([{"audio_codes": codes[0]}])
return wavs[0], rate
wav, rate = say("I really thought this would work.", armin,
"Speak in a sad, sorrowful tone, voice low and heavy, on the verge of tears.")
화자 슬롯에 넣을 화자와 모델, instruction을 골라 보세요.
복제한 목소리에도 instruction이 먹힙니다. 기본 제공 화자보다는 안정성이 떨어지고 감정에 따라 편차도 있지만, 솔직히 기대했던 것보다 결과가 좋았습니다. 다른 게임에서 들어 본 듯한 목소리가 아닌, 진짜 나만의 목소리로 더빙을 하기에 충분한 수준입니다.
목소리가 2048차원 공간의 한 점이고 블렌딩이 통한다면, 다른 재미있는 연산도 가능하지 않을까요? 가장 먼저 떠오르는 건 연산으로 성별을 바꿀 수 있는지 확인하는 것입니다.
방향을 찾기 위해 VoiceDesign으로 남성과 여성 목소리를 각각 14개씩 만들었습니다. 여성 평균에서 남성 평균을 빼면 그 방향이 나오고, 어떤 목소리든 이 방향으로 이동시킬 수 있습니다.
women = torch.stack([fingerprint(f"design/f{i}.wav") for i in range(14)]).float()
men = torch.stack([fingerprint(f"design/m{i}.wav") for i in range(14)]).float()
middle = (women.mean(0) + men.mean(0)) / 2
axis = women.mean(0) - men.mean(0)
axis = axis / axis.norm()
def move(xvec, t):
"""Put the voice at position t along the gender axis, keep the rest."""
x = xvec.float()
return (x + (t - (x - middle) @ axis) * axis).to(torch.bfloat16)
제 복제 목소리에 적용한 결과입니다.
이상입니다. Qwen3 TTS를 직접 써 보고 싶은 분들께 도움이 되었길 바라며, voice embedding으로 무엇을 할 수 있을지 아이디어도 얻으셨으면 좋겠습니다. 선한 일에만 쓰세요.