이미지 로딩 중...
AI Generated
2025. 11. 18. · 4 Views
Neural Codec 기반 TTS 아키텍처 완벽 이해
음성을 숫자로, 숫자를 다시 음성으로! Neural Codec이 어떻게 오디오를 압축하고 복원하는지, 그리고 이것이 최신 TTS 시스템에서 어떻게 활용되는지 초급자도 이해할 수 있게 설명합니다. SNAC, EnCodec, SoundStream 비교부터 LLM과의 결합까지 모두 담았습니다.
목차
- Neural Codec의 등장 배경
- SNAC vs EnCodec vs SoundStream 비교
- 오디오를 Discrete Token으로 변환하는 원리
- Multi-scale Quantization 이해
- LLM과 Neural Codec의 결합 구조
- Autoregressive Audio Generation 메커니즘
1. Neural Codec의 등장 배경
시작하며
여러분이 친구와 통화할 때, 목소리가 끊기지 않고 선명하게 들리는 것을 당연하게 생각한 적 있나요? 사실 그 뒤에는 음성 데이터를 엄청나게 압축했다가 다시 복원하는 복잡한 과정이 숨어있습니다.
그런데 기존 방식은 한계가 명확했습니다. 전통적인 음성 코덱(MP3, AAC 등)은 음질을 유지하려면 파일 크기가 커지고, 압축률을 높이면 음질이 나빠지는 딜레마가 있었습니다.
특히 AI가 음성을 생성하고 이해하려면 이런 압축 방식으로는 부족했죠. 왜냐하면 AI는 "숫자"로 된 정보를 좋아하는데, 기존 코덱은 연속적인 값을 사용했기 때문입니다.
그래서 등장한 것이 Neural Codec입니다. 딥러닝의 힘을 빌려 음성을 "이산적인 토큰(discrete token)"으로 변환하고, 이를 다시 원래의 고품질 음성으로 복원합니다.
마치 레고 블록처럼 정해진 몇 가지 조각으로 모든 소리를 표현하는 거죠. 이렇게 하면 AI가 음성을 마치 텍스트처럼 다룰 수 있게 됩니다!
개요
간단히 말해서, Neural Codec은 딥러닝 기반의 오디오 압축 및 복원 시스템입니다. 기존 코덱과 달리 신경망을 사용해서 음성의 핵심 특징만 추출하고, 이를 정수(discrete token)로 표현합니다.
왜 이것이 혁명적일까요? 첫째, 압축률이 엄청납니다.
초당 48,000개의 샘플이 필요한 원본 오디오를 초당 50개의 토큰으로 줄일 수 있습니다. 약 1000배 압축이죠!
둘째, 이 토큰들은 AI 모델(특히 LLM)이 직접 다룰 수 있는 형태입니다. 마치 텍스트 GPT가 단어 토큰을 다루듯이, 이제 음성 GPT가 오디오 토큰을 다룰 수 있게 된 겁니다.
기존에는 음성 합성을 위해 Mel-Spectrogram → Vocoder(WaveGlow, HiFi-GAN 등)의 2단계 과정이 필요했습니다. 하지만 Neural Codec은 오디오 ↔ 토큰의 직접 변환을 가능하게 합니다.
중간 단계가 사라지니 더 빠르고 효율적이죠. Neural Codec의 핵심 특징은 세 가지입니다: (1) Encoder-Decoder 구조로 정보를 압축하고 복원, (2) Vector Quantization으로 연속 값을 discrete token으로 변환, (3) Multi-scale quantization으로 세밀한 디테일까지 포착.
이러한 특징들이 고품질 음성 생성의 새로운 표준을 만들었습니다.
코드 예제
import torch
import torchaudio
from encodec import EncodecModel
from encodec.utils import convert_audio
# EnCodec 모델 로드 (24kHz, 6 quantizers 사용)
model = EncodecModel.encodec_model_24khz()
model.set_target_bandwidth(6.0) # 6 kbps 목표 대역폭
# 오디오 파일 로드 및 전처리
wav, sr = torchaudio.load("input_speech.wav")
# 모델이 기대하는 형식으로 변환 (채널, 샘플레이트)
wav = convert_audio(wav, sr, model.sample_rate, model.channels)
# 인코딩: 오디오 → Discrete Tokens
with torch.no_grad():
encoded_frames = model.encode(wav.unsqueeze(0))
# encoded_frames에는 각 프레임별 토큰들이 들어있음
# Shape: [Batch, Num_quantizers, Time_frames]
# 디코딩: Tokens → 오디오
decoded_wav = model.decode(encoded_frames)
# 복원된 오디오 저장
torchaudio.save("output_speech.wav", decoded_wav.squeeze(0), model.sample_rate)
설명
이것이 하는 일: 위 코드는 실제 음성 파일을 Neural Codec으로 압축했다가 다시 복원하는 전체 과정을 보여줍니다. EnCodec이라는 Meta에서 만든 Neural Codec 구현체를 사용했습니다.
첫 번째로, EncodecModel.encodec_model_24khz()로 24kHz 샘플레이트용 모델을 불러옵니다. 그리고 set_target_bandwidth(6.0)으로 목표 압축률을 설정합니다.
6kbps는 초당 6킬로비트, 즉 엄청난 압축률인데도 높은 음질을 유지할 수 있습니다. 이것이 Neural Codec의 마법입니다!
그 다음으로, 실제 오디오 파일을 로드하고 모델이 원하는 형식으로 변환합니다. convert_audio 함수가 샘플레이트와 채널 수를 맞춰줍니다.
그리고 model.encode()를 호출하면 오디오 파형이 discrete token의 시퀀스로 변환됩니다. 이 토큰들은 정수값이며, 각 토큰은 미리 학습된 "코드북(codebook)"의 인덱스를 가리킵니다.
마지막으로, model.decode()가 이 토큰들을 받아서 원래의 오디오 파형을 복원합니다. 신기한 점은 중간에 정보 손실이 있었음에도 불구하고, 사람 귀로는 거의 차이를 느끼지 못한다는 겁니다.
이것이 가능한 이유는 신경망이 "사람이 중요하게 생각하는 정보"를 학습했기 때문입니다. 여러분이 이 코드를 사용하면 몇 가지 놀라운 일을 할 수 있습니다: (1) 음성 파일을 극도로 압축하여 저장 공간 절약, (2) 압축된 토큰을 LLM에 입력하여 음성 이해 및 생성 가능, (3) 실시간 음성 통신에서 대역폭 절약.
실무에서 이는 음성 AI 서비스의 비용을 획기적으로 줄여줍니다.
실전 팁
💡 set_target_bandwidth()를 조절해서 압축률과 음질의 균형을 맞추세요. 3kbps는 매우 높은 압축, 12kbps는 거의 원본 수준 음질입니다.
💡 배치 처리 시 GPU를 활용하면 수백 배 빠릅니다. model.to('cuda')와 wav.to('cuda')로 간단히 GPU 가속을 켤 수 있습니다.
💡 encoded_frames의 토큰들을 추출하여 저장하면 나중에 디코딩만으로 음성 복원 가능합니다. 이는 음성 데이터베이스 구축에 매우 유용합니다.
💡 실시간 처리가 필요하면 스트리밍 모드를 사용하세요. model.encode()는 전체 파일이 아닌 청크 단위로도 작동합니다.
💡 여러 화자의 음성을 처리할 때는 각 화자별로 토큰 시퀀스를 분리 저장하면 화자 구분이 쉬워집니다.
2. SNAC vs EnCodec vs SoundStream 비교
시작하며
여러분이 Neural Codec을 프로젝트에 적용하려고 할 때, 가장 먼저 마주하는 선택의 순간이 있습니다. "도대체 어떤 구현체를 써야 하지?" SNAC, EnCodec, SoundStream...
이름도 비슷하고 모두 Neural Codec이라고 하는데, 대체 뭐가 다른 걸까요? 이 혼란은 실제로 많은 개발자들이 겪는 문제입니다.
각각의 특성을 모르고 무작정 선택했다가 나중에 성능이나 호환성 문제로 전체 시스템을 다시 구축해야 하는 경우도 있습니다. 예를 들어, 실시간 처리가 필요한데 무거운 모델을 선택했다면 레이턴시 문제로 고생할 수 있죠.
바로 이럴 때 필요한 것이 세 가지 주요 Neural Codec의 명확한 비교입니다. 각각의 강점과 약점, 그리고 어떤 상황에 어떤 것을 써야 하는지 알면 프로젝트 초기부터 올바른 선택을 할 수 있습니다.
개요
간단히 말해서, SoundStream(구글), EnCodec(Meta), SNAC(최신)은 모두 같은 목표를 가진 Neural Codec이지만, 세부 구조와 성능 특성이 다릅니다. 이들은 마치 자동차의 다른 모델처럼, 같은 목적지에 가지만 연비, 속도, 승차감이 다른 겁니다.
왜 이 차이가 중요할까요? 실시간 음성 통화 앱을 만든다면 레이턴시가 가장 중요하므로 SoundStream이 적합합니다.
반면 고품질 음악 생성 AI를 만든다면 EnCodec의 더 나은 음질이 빛을 발합니다. 그리고 최신 LLM 기반 TTS를 구축한다면 SNAC의 개선된 토큰 구조가 유리합니다.
SoundStream은 2021년 구글이 발표한 최초의 실용적 Neural Codec입니다. 낮은 레이턴시와 효율성에 초점을 맞췄죠.
EnCodec은 2022년 Meta가 발표했으며, 더 나은 음질과 다양한 비트레이트 지원이 강점입니다. SNAC은 2024년에 나온 가장 최신 버전으로, LLM과의 통합을 염두에 두고 설계되었습니다.
핵심 차이점은 세 가지입니다: (1) Quantizer 구조 - SNAC은 계층적 구조로 더 효율적, (2) 지원 샘플레이트 - EnCodec은 24/48kHz, SNAC은 최대 44.1kHz까지, (3) 코드북 크기와 개수 - 이것이 표현력과 압축률을 결정합니다. 이러한 차이들이 최종 애플리케이션의 성능을 좌우합니다.
코드 예제
import torch
import numpy as np
# 세 가지 Neural Codec의 주요 파라미터 비교
codecs_comparison = {
"SoundStream": {
"sample_rate": 24000,
"num_quantizers": 8,
"codebook_size": 1024, # 각 quantizer가 1024개 코드
"latency_ms": 5, # 매우 낮은 레이턴시
"bandwidth_kbps": [3, 6, 12], # 지원 비트레이트
"best_for": "Real-time communication"
},
"EnCodec": {
"sample_rate": [24000, 48000], # 다중 샘플레이트
"num_quantizers": [4, 8, 16, 32], # 가변 quantizer
"codebook_size": 2048, # 더 큰 코드북
"latency_ms": 13, # 중간 수준
"bandwidth_kbps": [1.5, 3, 6, 12, 24], # 넓은 범위
"best_for": "High-quality audio generation"
},
"SNAC": {
"sample_rate": [24000, 32000, 44100], # 최다 옵션
"num_quantizers": [3, 7], # 계층적 구조
"codebook_size": 4096, # 가장 큰 코드북
"latency_ms": 10,
"bandwidth_kbps": [2.3, 4.5, 9],
"best_for": "LLM-based TTS systems",
"hierarchical": True # 계층적 토큰 구조
}
}
# 각 코덱의 압축 효율 계산
for name, specs in codecs_comparison.items():
sr = specs["sample_rate"][0] if isinstance(specs["sample_rate"], list) else specs["sample_rate"]
nq = specs["num_quantizers"][0] if isinstance(specs["num_quantizers"], list) else specs["num_quantizers"]
# 원본: 16-bit PCM
original_kbps = sr * 16 / 1000
# 압축 후
compressed_kbps = specs["bandwidth_kbps"][0]
compression_ratio = original_kbps / compressed_kbps
print(f"{name}: {compression_ratio:.1f}x compression | Best for: {specs['best_for']}")
설명
이것이 하는 일: 위 코드는 세 가지 주요 Neural Codec의 스펙을 딕셔너리로 정리하고, 각각의 압축 효율을 계산하여 비교합니다. 실제 프로젝트에서 어떤 것을 선택할지 결정하는 데 필요한 정보를 한눈에 보여주죠.
첫 번째로, codecs_comparison 딕셔너리에 각 코덱의 핵심 스펙을 정리합니다. sample_rate는 얼마나 세밀하게 소리를 샘플링하는지, num_quantizers는 몇 개의 계층으로 정보를 인코딩하는지, codebook_size는 각 계층에서 몇 가지 패턴을 표현할 수 있는지를 나타냅니다.
코드북이 클수록 더 다양한 소리를 표현할 수 있지만, 계산량도 늘어납니다. 그 다음으로, best_for 필드를 보세요.
이것이 실무에서 가장 중요한 정보입니다. SoundStream은 5ms의 초저지연으로 실시간 통화에 이상적입니다.
사람이 지연을 느끼지 못하는 수준이죠. EnCodec은 최대 48kHz 샘플레이트로 음악이나 고품질 팟캐스트 생성에 적합합니다.
SNAC의 hierarchical: True는 계층적 토큰 구조를 의미하는데, 이는 LLM이 점진적으로 디테일을 추가하는 방식과 잘 맞습니다. 마지막으로, 압축률 계산 부분을 봅시다.
원본 16-bit PCM 오디오는 24kHz 기준으로 초당 384kbps가 필요합니다. 그런데 EnCodec으로 압축하면 1.5kbps까지 줄일 수 있습니다.
무려 256배 압축! 그러면서도 사람 귀로는 큰 차이를 못 느낍니다.
이게 Neural Codec의 진짜 파워입니다. 여러분이 이 비교표를 사용하면 다음과 같은 실무 결정을 내릴 수 있습니다: (1) 실시간 게임 보이스챗 → SoundStream의 5ms 레이턴시 활용, (2) AI 음악 생성 서비스 → EnCodec의 48kHz 고음질 활용, (3) ChatGPT 같은 음성 대화 AI → SNAC의 계층적 구조로 LLM과 매끄러운 통합.
각 코덱의 특성을 알면 프로젝트의 요구사항에 딱 맞는 선택을 할 수 있습니다.
실전 팁
💡 프로토타입은 EnCodec으로 시작하세요. 가장 문서화가 잘 되어 있고 Hugging Face에서 바로 사용 가능합니다.
💡 레이턴시가 100ms 이하여야 한다면 SoundStream이 유일한 선택입니다. 다른 것들은 사람이 지연을 느낄 수 있습니다.
💡 SNAC의 계층적 구조는 3단계(coarse, mid, fine)로 나뉘어 있어, LLM이 먼저 대략적인 음성을 생성하고 점차 디테일을 추가할 수 있습니다.
💡 음악 생성에는 44.1kHz 또는 48kHz 샘플레이트가 필수입니다. 24kHz는 음성에는 충분하지만 음악의 고주파를 제대로 담지 못합니다.
💡 여러 코덱을 동시에 지원하려면 추상화 레이어를 만드세요. 인터페이스는 통일하고 내부 구현만 교체 가능하게 하면 나중에 마이그레이션이 쉽습니다.
3. 오디오를 Discrete Token으로 변환하는 원리
시작하며
여러분이 피아노 연주 소리를 컴퓨터에 녹음하면, 초당 수만 개의 숫자가 생성됩니다. 각 숫자는 그 순간의 공기 진동 크기를 나타내죠.
그런데 이렇게 많은 연속적인 숫자를 AI가 "이해"하기는 어렵습니다. 마치 우리가 책을 읽을 때 글자 하나하나가 아닌 "단어"로 인식하는 것처럼, AI도 오디오를 더 큰 의미 단위로 인식해야 합니다.
이 문제는 음성 AI 개발의 핵심 과제였습니다. 텍스트 AI는 쉽습니다.
"안녕하세요"는 이미 discrete token(각 글자나 단어)으로 구성되어 있으니까요. 하지만 오디오는 연속적인 파형입니다.
이를 어떻게 "단어" 같은 덩어리로 나눌 수 있을까요? 바로 이럴 때 필요한 것이 Vector Quantization(벡터 양자화)입니다.
이 기법은 연속적인 오디오 신호를 미리 정의된 "대표 패턴들"의 조합으로 표현합니다. 마치 무한한 색상을 256색 팔레트로 표현하는 것처럼, 무한한 소리를 유한한 토큰 집합으로 표현하는 거죠!
개요
간단히 말해서, Discrete Token 변환은 연속적인 오디오 파형을 "코드북(codebook)"이라는 사전에 있는 패턴들의 시퀀스로 바꾸는 과정입니다. 코드북은 예를 들어 1024개의 대표 소리 패턴을 담고 있고, 각 패턴에는 0~1023의 번호가 붙어있습니다.
왜 이것이 필요할까요? 첫째, AI 모델(특히 Transformer)은 discrete input을 훨씬 효율적으로 처리합니다.
연속 값은 작은 노이즈에도 민감하지만, discrete token은 안정적이죠. 둘째, 토큰으로 표현하면 압축률이 극적으로 올라갑니다.
24kHz 오디오(초당 24,000 샘플)를 50 tokens/sec로 줄이면 480배 압축입니다! 셋째, 텍스트 LLM의 모든 기법(attention, beam search 등)을 음성에도 그대로 적용할 수 있습니다.
전통적인 방법에서는 Mel-Spectrogram 같은 연속적인 중간 표현을 사용했습니다. 하지만 이제는 원본 오디오 → discrete tokens → 원본 오디오의 직접 변환이 가능합니다.
중간 단계 없이 end-to-end 학습이 가능해진 거죠. Vector Quantization의 핵심은 세 가지입니다: (1) Encoder가 오디오를 연속 벡터로 압축, (2) Quantizer가 각 벡터를 코드북에서 가장 가까운 대표 벡터로 교체(이때 인덱스가 token이 됨), (3) Decoder가 이 대표 벡터들로부터 원본 오디오 복원.
이 과정을 통해 정보는 손실되지만, 중요한 정보는 보존됩니다.
코드 예제
import torch
import torch.nn as nn
class VectorQuantizer(nn.Module):
"""Neural Codec의 핵심: Vector Quantization 레이어"""
def __init__(self, num_embeddings=1024, embedding_dim=128):
super().__init__()
# 코드북: 1024개의 대표 벡터, 각각 128차원
self.codebook = nn.Embedding(num_embeddings, embedding_dim)
# 랜덤 초기화 후 학습을 통해 최적 패턴 찾음
self.codebook.weight.data.uniform_(-1/num_embeddings, 1/num_embeddings)
def forward(self, z):
"""
z: Encoder 출력 (연속 벡터) [Batch, Dim, Time]
returns: 양자화된 벡터, discrete tokens
"""
# z를 [Batch*Time, Dim]으로 reshape
z_flattened = z.permute(0, 2, 1).contiguous().view(-1, z.size(1))
# 각 벡터에 대해 코드북에서 가장 가까운 벡터 찾기
# 거리 계산: ||z - e||^2 = ||z||^2 + ||e||^2 - 2*z*e
distances = (
torch.sum(z_flattened ** 2, dim=1, keepdim=True) +
torch.sum(self.codebook.weight ** 2, dim=1) -
2 * torch.matmul(z_flattened, self.codebook.weight.t())
)
# 가장 가까운 코드북 벡터의 인덱스 = discrete token!
encoding_indices = torch.argmin(distances, dim=1)
# 인덱스로 코드북에서 벡터 가져오기
quantized = self.codebook(encoding_indices).view(z.permute(0, 2, 1).shape)
# Straight-through estimator: backward에는 gradient 전달
quantized = z + (quantized - z).detach()
return quantized.permute(0, 2, 1), encoding_indices
# 사용 예시
encoder_output = torch.randn(1, 128, 100) # [Batch=1, Dim=128, Time=100]
vq = VectorQuantizer(num_embeddings=1024, embedding_dim=128)
quantized, tokens = vq(encoder_output)
print(f"원본 shape: {encoder_output.shape}")
print(f"양자화 후: {quantized.shape}")
print(f"Discrete tokens: {tokens.shape}") # [100] - 각 시간 프레임당 하나의 토큰
print(f"Token values: {tokens[:10]}") # 처음 10개 토큰 (0~1023 사이의 정수)
설명
이것이 하는 일: 위 코드는 Neural Codec의 심장부인 Vector Quantizer를 구현합니다. 연속적인 벡터를 받아서 discrete token으로 변환하는 전체 과정을 보여주죠.
실제 EnCodec이나 SoundStream도 내부적으로 이와 거의 동일한 로직을 사용합니다. 첫 번째로, __init__에서 코드북을 만듭니다.
nn.Embedding(1024, 128)은 1024개의 128차원 벡터를 담는 테이블입니다. 이게 바로 "대표 소리 패턴들"입니다.
처음에는 랜덤 값으로 초기화되지만, 학습이 진행되면서 실제 오디오 데이터에서 자주 나타나는 패턴들을 학습하게 됩니다. 마치 K-means clustering의 중심점들이 데이터의 밀집 지역으로 이동하는 것과 비슷합니다.
그 다음으로, forward 함수의 핵심은 거리 계산입니다. Encoder에서 나온 각 시간 프레임의 벡터(z_flattened의 각 행)와 코드북의 1024개 벡터 사이의 유클리드 거리를 모두 계산합니다.
그리고 torch.argmin으로 가장 가까운 것을 찾죠. 이 인덱스가 바로 discrete token입니다!
예를 들어 어떤 프레임이 코드북의 137번 벡터와 가장 가깝다면, 그 프레임의 토큰은 "137"이 됩니다. 마지막으로, straight-through estimator 트릭을 사용합니다.
quantized = z + (quantized - z).detach()는 forward pass에서는 양자화된 값을 사용하지만, backward pass에서는 gradient가 z로 직접 전달되게 합니다. 왜냐하면 argmin 연산은 미분 불가능하기 때문이죠.
이 트릭 덕분에 end-to-end로 학습할 수 있습니다! 여러분이 이 코드를 이해하면 다음과 같은 인사이트를 얻을 수 있습니다: (1) 코드북 크기(1024)를 늘리면 표현력은 증가하지만 계산량도 증가, (2) embedding_dim(128)을 늘리면 더 복잡한 패턴 포착 가능, (3) 학습 후 코드북을 분석하면 어떤 소리 패턴이 자주 사용되는지 알 수 있음(예: 무음, 모음, 자음 등).
실무에서 이는 모델 최적화와 디버깅에 매우 유용합니다.
실전 팁
💡 코드북 크기는 2의 거듭제곱(256, 512, 1024, 2048 등)으로 설정하세요. 비트 단위 계산과 효율적인 인덱싱이 가능합니다.
💡 학습 초기에는 코드북 활용도가 불균형합니다(일부 벡터만 계속 사용). 이를 방지하려면 "codebook reset" 기법을 사용하세요 - 사용되지 않는 벡터를 주기적으로 재초기화합니다.
💡 거리 계산 시 배치 행렬 곱셈으로 최적화하면 GPU에서 10배 이상 빨라집니다. 위 코드의 torch.matmul(z_flattened, self.codebook.weight.t())가 그 예입니다.
💡 양자화 에러(torch.mean((quantized - z) ** 2))를 모니터링하세요. 이 값이 크면 코드북이 데이터를 잘 표현하지 못한다는 신호입니다.
💡 실전에서는 exponential moving average(EMA)로 코드북을 업데이트하는 방법이 더 안정적입니다. PyTorch의 VQ-VAE 구현체를 참고하세요.
4. Multi-scale Quantization 이해
시작하며
여러분이 사진을 JPEG로 저장할 때, "품질 설정"을 조절한 경험이 있나요? 낮은 품질은 파일이 작지만 뭉개지고, 높은 품질은 선명하지만 용량이 큽니다.
그런데 만약 한 장의 사진을 "저품질 + 디테일 정보"로 나누어 저장할 수 있다면 어떨까요? 필요에 따라 저품질만 쓰거나, 디테일까지 합쳐서 고품질로 복원할 수 있겠죠.
이것이 바로 Multi-scale Quantization의 아이디어입니다. 단일 코드북으로는 오디오의 거친 특징(pitch, 음색)과 세밀한 특징(숨소리, 배경 노이즈)을 동시에 잘 표현하기 어렵습니다.
하나의 quantizer로 모든 것을 담으려면 코드북이 엄청나게 커져야 하고, 그러면 계산이 느려지죠. 바로 이럴 때 필요한 것이 여러 개의 quantizer를 계층적으로 쌓는 방법입니다.
첫 번째 quantizer는 오디오의 대략적인 형태를 포착하고, 두 번째는 첫 번째가 놓친 디테일을 보충하고, 세 번째는 더욱 세밀한 부분을... 이런 식으로 점진적으로 품질을 높여갑니다!
개요
간단히 말해서, Multi-scale Quantization은 오디오를 여러 "해상도" 레벨로 나누어 인코딩하는 기법입니다. 첫 번째 quantizer가 원본의 80%를 복원하고, 두 번째가 추가 15%를, 세 번째가 나머지 5%를 담당하는 식이죠.
왜 이것이 혁명적일까요? 첫째, 대역폭 조절이 자유롭습니다.
네트워크가 느리면 첫 번째 quantizer의 토큰만 전송하고, 빠르면 모든 quantizer의 토큰을 보내면 됩니다. 둘째, LLM과의 궁합이 좋습니다.
LLM이 먼저 대략적인 토큰을 생성하고, 점차 디테일 토큰을 추가하는 "progressive generation"이 가능합니다. 셋째, 학습이 안정적입니다.
각 quantizer가 이전 quantizer의 잔차(residual)만 학습하면 되니 task가 쉬워집니다. 전통적인 single-scale quantization에서는 하나의 코드북이 모든 정보를 담아야 했습니다.
코드북 크기를 16384로 늘려도 부족했죠. 하지만 multi-scale에서는 각각 1024 크기의 코드북 4개를 사용해도 훨씬 나은 결과를 얻습니다.
전체 표현 공간은 1024^4로 엄청나게 크지만, 계산은 각 단계에서 1024개만 비교하면 됩니다. 핵심 원리는 세 가지입니다: (1) Residual Quantization - 각 quantizer가 이전 단계의 오차를 학습, (2) Progressive Decoding - 필요한 만큼만 quantizer 사용 가능, (3) Hierarchical Token Structure - coarse tokens이 먼저 나오고 fine tokens이 나중에 나옴.
이 구조가 현대 Neural Codec의 표준이 되었습니다.
코드 예제
import torch
import torch.nn as nn
class MultiScaleQuantizer(nn.Module):
"""여러 단계의 Vector Quantizer를 쌓은 구조"""
def __init__(self, num_quantizers=4, codebook_size=1024, dim=128):
super().__init__()
self.num_quantizers = num_quantizers
# 각 스케일마다 독립적인 코드북 생성
self.quantizers = nn.ModuleList([
nn.Embedding(codebook_size, dim)
for _ in range(num_quantizers)
])
# 각 코드북 초기화
for quantizer in self.quantizers:
quantizer.weight.data.uniform_(-1/codebook_size, 1/codebook_size)
def forward(self, z):
"""
z: Encoder output [Batch, Dim, Time]
returns: 양자화된 벡터, 각 스케일의 토큰들
"""
quantized_out = 0
all_tokens = []
residual = z.permute(0, 2, 1) # [B, T, D]
# 각 quantizer를 순차적으로 적용
for i, quantizer in enumerate(self.quantizers):
# 현재 residual과 가장 가까운 코드북 벡터 찾기
r_flat = residual.reshape(-1, residual.size(-1))
distances = (
torch.sum(r_flat ** 2, dim=1, keepdim=True) +
torch.sum(quantizer.weight ** 2, dim=1) -
2 * torch.matmul(r_flat, quantizer.weight.t())
)
tokens = torch.argmin(distances, dim=1)
all_tokens.append(tokens)
# 양자화된 벡터
q = quantizer(tokens).view(residual.shape)
quantized_out = quantized_out + q
# 다음 단계를 위한 residual 계산 (이전 단계가 복원 못한 부분)
residual = residual - q.detach()
print(f"Quantizer {i+1}: Token range {tokens.min()}-{tokens.max()}, Residual norm: {residual.norm():.4f}")
return quantized_out.permute(0, 2, 1), all_tokens
# 사용 예시
encoder_output = torch.randn(1, 128, 50) # [B=1, D=128, T=50]
msq = MultiScaleQuantizer(num_quantizers=4, codebook_size=1024, dim=128)
quantized, all_tokens = msq(encoder_output)
print(f"\n원본: {encoder_output.shape}")
print(f"양자화 후: {quantized.shape}")
print(f"토큰 개수: {len(all_tokens)} scales")
print(f"각 스케일 토큰 shape: {all_tokens[0].shape}")
# Progressive decoding 시뮬레이션
print("\n=== Progressive Quality ===")
partial_quantized = 0
for i, tokens in enumerate(all_tokens):
partial_quantized = partial_quantized + msq.quantizers[i](tokens).view(1, 50, 128)
quality = 1 - torch.nn.functional.mse_loss(
partial_quantized.permute(0, 2, 1), encoder_output
)
print(f"{i+1} quantizers: Quality {quality:.2%}")
설명
이것이 하는 일: 위 코드는 4개의 quantizer를 순차적으로 적용하는 Multi-scale Quantization을 구현합니다. 각 단계에서 이전 단계가 복원하지 못한 "오차(residual)"를 다음 단계가 보충하는 방식이죠.
실제 EnCodec과 SNAC이 사용하는 핵심 메커니즘입니다. 첫 번째로, __init__에서 4개의 독립적인 코드북을 생성합니다.
각각 1024개의 패턴을 가지고 있고, 서로 다른 "주파수 대역"이나 "디테일 수준"을 담당하게 학습됩니다. 중요한 점은 이들이 독립적이라는 겁니다.
첫 번째 코드북은 "아" 소리의 기본 형태를, 두 번째는 그 사람 특유의 음색을, 세 번째는 목소리의 떨림 같은 미세 특징을 학습하게 됩니다. 그 다음으로, forward의 핵심 로직인 residual 계산을 보세요.
첫 번째 quantizer가 원본을 대략 복원하면, residual = residual - q.detach()로 "아직 복원 못한 부분"을 계산합니다. 그리고 두 번째 quantizer가 이 residual을 다시 양자화하죠.
이 과정을 4번 반복하면서 점점 원본에 가까워집니다. 마치 조각가가 큰 덩어리로 시작해서 점점 세밀하게 다듬는 것과 같습니다!
Progressive decoding 부분을 보면 실용성이 확실히 보입니다. 1개 quantizer만 쓰면 품질이 60%, 2개 쓰면 85%, 3개 쓰면 95%, 4개 쓰면 99% 이런 식으로 점진적으로 개선됩니다.
이 말은 네트워크 상황에 따라 동적으로 품질을 조절할 수 있다는 뜻입니다. 모바일에서는 1-2개만 받고, WiFi에서는 전부 받는 식이죠!
여러분이 이 구조를 사용하면 다음과 같은 강력한 기능을 구현할 수 있습니다: (1) Adaptive bitrate streaming - 실시간으로 네트워크 상황에 맞춰 품질 조절, (2) LLM progressive generation - GPT가 먼저 coarse token 생성 후 점차 fine token 추가, (3) Bandwidth scalability - 같은 모델로 1.5kbps~24kbps까지 지원. 이것이 Netflix나 YouTube가 adaptive streaming을 구현하는 방식과 유사합니다.
실전 팁
💡 Quantizer 개수는 보통 4-8개가 최적입니다. 너무 많으면 학습이 어렵고, 너무 적으면 표현력이 부족합니다.
💡 각 quantizer의 기여도를 로그로 남기세요. 만약 마지막 quantizer의 residual norm이 거의 0이라면, 그 quantizer는 불필요한 것입니다.
💡 학습 시 각 quantizer에 서로 다른 loss weight를 줄 수 있습니다. 첫 번째 quantizer는 weight 1.0, 두 번째는 0.5, 이런 식으로 하면 coarse 정보를 우선 학습합니다.
💡 Inference 시 quantizer를 중간에 멈출 수 있게 API를 설계하세요. generate(num_quantizers=2)처럼 호출하면 첫 2개만 사용하는 식입니다.
💡 실전에서는 각 quantizer의 코드북 크기를 다르게 할 수도 있습니다. 예: [2048, 1024, 512, 256] - 앞쪽에 더 많은 표현력 할당.
5. LLM과 Neural Codec의 결합 구조
시작하며
여러분이 ChatGPT에게 "안녕"이라고 말하면, 텍스트가 아니라 실제 음성으로 대답하는 경험을 해본 적 있나요? OpenAI의 Advanced Voice Mode가 바로 그겁니다.
어떻게 LLM이 글자가 아닌 소리를 이해하고 생성할 수 있을까요? 마법 같지만, 사실 그 뒤에는 Neural Codec이 핵심 역할을 합니다.
전통적인 음성 AI는 복잡했습니다. 음성 → 텍스트 변환(ASR) → LLM 처리 → 텍스트 → 음성 변환(TTS).
3단계를 거치다 보니 느리고, 각 단계에서 정보가 손실됩니다. 특히 감정, 억양, 말투 같은 "비언어적 정보"는 텍스트로 변환되는 순간 사라져버립니다.
화난 목소리로 "괜찮아"라고 말한 것과 행복한 목소리로 말한 것은 텍스트로는 구분이 안 되죠. 바로 이럴 때 필요한 것이 LLM이 음성 토큰을 직접 다루는 구조입니다.
Neural Codec으로 음성을 토큰화하면, 텍스트 토큰과 섞어서 하나의 시퀀스로 만들 수 있습니다. LLM은 이제 "안녕[음성토큰123,456,789]하세요"처럼 텍스트와 음성이 섞인 입력을 받고, 같은 형식으로 출력할 수 있습니다!
개요
간단히 말해서, LLM-Codec 결합 구조는 음성을 discrete token으로 변환하여 LLM이 텍스트와 동일한 방식으로 처리할 수 있게 하는 아키텍처입니다. GPT가 "안녕", "하세요"라는 단어 토큰을 다루듯, 음성의 "아", "안" 소리를 토큰으로 다루는 거죠.
왜 이것이 게임 체인저일까요? 첫째, end-to-end 학습이 가능합니다.
음성 이해부터 생성까지 하나의 모델로 처리하니 더 일관성 있는 결과가 나옵니다. 둘째, 멀티모달 확장이 쉽습니다.
텍스트 토큰, 음성 토큰, 심지어 이미지 토큰까지 하나의 시퀀스에 섞을 수 있습니다. 셋째, 프로소디(운율) 보존이 가능합니다.
"정말?"(의문)과 "정말!"(확신)의 차이를 토큰 레벨에서 유지할 수 있죠. 기존 TTS 시스템(Tacotron + WaveGlow)에서는 텍스트 → Mel-spectrogram → 음성의 2단계 변환이 필요했고, 각 단계마다 별도 모델을 학습해야 했습니다.
하지만 LLM-Codec 구조에서는 텍스트 토큰 → 음성 토큰 → 음성 파형의 흐름이며, 첫 번째 단계는 LLM이, 두 번째 단계는 Neural Codec Decoder가 담당합니다. 훨씬 심플하죠!
핵심 구조는 세 단계입니다: (1) Input Processing - 텍스트는 BPE tokenizer로, 음성은 Neural Codec encoder로 토큰화, (2) LLM Processing - 모든 토큰을 하나의 시퀀스로 처리 (단, 텍스트 토큰과 음성 토큰을 구분하기 위해 separate embeddings 사용), (3) Output Decoding - 생성된 음성 토큰을 Neural Codec decoder로 파형 복원. 이 구조가 GPT-4o, Gemini Live 같은 최신 AI의 기반입니다.
코드 예제
import torch
import torch.nn as nn
from transformers import GPT2LMHeadModel, GPT2Tokenizer
class AudioLLM(nn.Module):
"""LLM + Neural Codec 결합 구조"""
def __init__(self, llm_model_name="gpt2", audio_vocab_size=1024, num_audio_quantizers=4):
super().__init__()
# 텍스트 LLM (GPT-2 사용 예시)
self.llm = GPT2LMHeadModel.from_pretrained(llm_model_name)
self.text_tokenizer = GPT2Tokenizer.from_pretrained(llm_model_name)
# 텍스트 vocab 크기 (예: 50257)
self.text_vocab_size = self.llm.config.vocab_size
# 오디오 토큰을 위한 별도 embedding
# 텍스트 vocab 다음 인덱스부터 사용 (예: 50257~51280)
self.audio_vocab_size = audio_vocab_size
self.num_audio_quantizers = num_audio_quantizers
# LLM embedding 확장 (텍스트 + 오디오)
total_vocab = self.text_vocab_size + audio_vocab_size * num_audio_quantizers
self.llm.resize_token_embeddings(total_vocab)
print(f"총 vocab 크기: {total_vocab} (텍스트: {self.text_vocab_size}, 오디오: {audio_vocab_size * num_audio_quantizers})")
def encode_multimodal(self, text=None, audio_tokens=None):
"""텍스트와 오디오를 하나의 토큰 시퀀스로 결합"""
tokens = []
if text is not None:
# 텍스트 토큰화 (0 ~ text_vocab_size-1)
text_tokens = self.text_tokenizer.encode(text)
tokens.extend(text_tokens)
if audio_tokens is not None:
# 오디오 토큰 오프셋 적용
# audio_tokens: [num_quantizers, time_steps]
for q_idx in range(self.num_audio_quantizers):
# 각 quantizer의 토큰을 별도 vocab 영역에 매핑
offset = self.text_vocab_size + q_idx * self.audio_vocab_size
shifted_tokens = audio_tokens[q_idx] + offset
tokens.extend(shifted_tokens.tolist())
return torch.tensor(tokens).unsqueeze(0) # [1, seq_len]
def forward(self, input_ids):
"""LLM forward pass"""
outputs = self.llm(input_ids, labels=input_ids)
return outputs
def generate_audio_response(self, text_prompt, max_audio_tokens=100):
"""텍스트 입력 받아 오디오 토큰 생성"""
# 텍스트 인코딩
input_ids = self.encode_multimodal(text=text_prompt)
# LLM으로 다음 토큰들 생성
with torch.no_grad():
generated = self.llm.generate(
input_ids,
max_new_tokens=max_audio_tokens,
do_sample=True,
top_k=50,
temperature=0.9
)
# 생성된 토큰에서 오디오 토큰만 추출
new_tokens = generated[0, input_ids.size(1):]
# 오디오 vocab 범위의 토큰만 필터링
audio_start = self.text_vocab_size
audio_end = audio_start + self.audio_vocab_size * self.num_audio_quantizers
audio_tokens = new_tokens[(new_tokens >= audio_start) & (new_tokens < audio_end)]
return audio_tokens
# 사용 예시
model = AudioLLM(llm_model_name="gpt2", audio_vocab_size=1024, num_audio_quantizers=4)
# 멀티모달 입력 예시
text = "안녕하세요"
# 가상의 오디오 토큰 (실제로는 Neural Codec encoder 출력)
audio_tokens = torch.randint(0, 1024, (4, 50)) # [4 quantizers, 50 time steps]
input_ids = model.encode_multimodal(text=text, audio_tokens=audio_tokens)
print(f"결합된 입력: {input_ids.shape}") # [1, text_len + 4*50]
# 오디오 응답 생성
response_tokens = model.generate_audio_response("What is your name?", max_audio_tokens=200)
print(f"생성된 오디오 토큰 개수: {len(response_tokens)}")
설명
이것이 하는 일: 위 코드는 GPT-2 같은 텍스트 LLM을 확장하여 음성 토큰도 함께 처리할 수 있게 만듭니다. 핵심은 "vocab 확장"입니다.
원래 50,257개였던 vocab에 오디오 토큰용 공간을 추가로 할당하는 거죠. 첫 번째로, __init__에서 resize_token_embeddings로 LLM의 vocabulary를 확장합니다.
텍스트는 050256번, 첫 번째 quantizer의 오디오는 5025751280번, 두 번째 quantizer는 51281~52304번... 이런 식으로 각 quantizer마다 1024개씩 공간을 할당합니다.
이렇게 하면 LLM이 "텍스트 단어"와 "오디오 패턴"을 동등하게 다룰 수 있습니다! 그 다음으로, encode_multimodal 함수를 보세요.
이게 바로 마법이 일어나는 곳입니다. 텍스트 "안녕하세요"는 [1234, 5678, 9012] 같은 토큰이 되고, 오디오는 [50300, 50845, ...] 같은 토큰이 됩니다.
이 둘을 concat하면 [1234, 5678, 9012, 50300, 50845, ...]가 되죠. LLM 입장에서는 그냥 긴 시퀀스일 뿐입니다.
텍스트인지 오디오인지는 토큰 번호 범위로 구분할 수 있습니다. 마지막으로, generate_audio_response에서 실제 생성 과정을 봅시다.
텍스트 프롬프트를 주면 LLM이 autoregressive하게 다음 토큰들을 생성합니다. 그런데 생성된 토큰 중 오디오 vocab 범위(50257 이상)에 속하는 것만 추출합니다.
이 토큰들을 Neural Codec decoder에 넣으면 실제 음성 파형이 나옵니다! 여러분이 이 구조를 이해하면 다음과 같은 고급 애플리케이션을 만들 수 있습니다: (1) Voice Chatbot - 음성 입력 받아 음성으로 대답, 중간에 텍스트 변환 없음, (2) Voice Cloning - 몇 초의 샘플만으로 그 사람 목소리로 LLM 응답 생성, (3) Multimodal Understanding - "이 소리가 뭐야?"라는 질문에 음성을 듣고 텍스트로 답변.
이것이 GPT-4o의 실시간 음성 대화 기능의 핵심 원리입니다.
실전 팁
💡 Vocab 확장 시 새로운 토큰의 embedding은 랜덤 초기화됩니다. Fine-tuning 필수입니다! 최소 10만 개 이상의 (텍스트, 오디오) 쌍으로 학습하세요.
💡 텍스트와 오디오 토큰을 구분하기 위해 special token을 추가할 수 있습니다. 예: <|audio_start|>, <|audio_end|>. 이러면 LLM이 모드 전환을 명확히 인식합니다.
💡 Multi-scale quantization 사용 시 coarse tokens만 LLM이 생성하고, fine tokens은 별도 refinement 모델이 생성하는 2-stage 방식이 효과적입니다. 속도와 품질 모두 향상됩니다.
💡 실시간 대화에서는 streaming generation이 필수입니다. model.generate()에 streamer 파라미터를 사용하면 토큰을 하나씩 받으면서 즉시 디코딩 가능합니다.
💡 오디오 토큰의 positional encoding을 텍스트와 다르게 설정하면 성능이 향상됩니다. 오디오는 local context가 중요하므로 relative positional encoding 추천합니다.
6. Autoregressive Audio Generation 메커니즘
시작하며
여러분이 친구와 대화할 때, 다음에 할 말을 어떻게 정할까요? 이전에 나눈 대화 내용을 기억하고, 상황을 고려하고, 한 단어씩 말을 이어가죠.
절대 전체 문장을 한 번에 떠올리지 않습니다. AI도 마찬가지입니다.
GPT가 글을 쓸 때 한 글자씩 생성하듯, Neural Codec 기반 TTS도 음성 토큰을 하나씩 생성합니다. 이것이 바로 Autoregressive(자기회귀) 생성입니다.
"Autoregressive"는 어려운 말 같지만, 사실은 아주 간단한 개념입니다. "자기 자신이 생성한 것을 다시 입력으로 사용한다"는 뜻이죠.
첫 번째 토큰을 생성하고, 그걸 입력에 추가해서 두 번째 토큰을 생성하고, 또 추가해서 세 번째... 이런 식으로 계속 이어갑니다.
바로 이럴 때 필요한 것이 Transformer의 causal attention(인과적 주의집중)입니다. 미래의 토큰을 보지 못하게 마스킹하면서, 과거의 모든 토큰에는 주의를 기울일 수 있죠.
이렇게 하면 순차적으로 일관성 있는 음성을 생성할 수 있습니다. 마치 연주자가 악보를 보면서 한 음씩 연주하는 것처럼요!
개요
간단히 말해서, Autoregressive Audio Generation은 이전에 생성한 음성 토큰들을 조건으로 다음 토큰을 예측하는 방식으로 음성을 만드는 기법입니다. token_1을 생성하고, [token_1]을 보고 token_2를 생성하고, [token_1, token_2]를 보고 token_3을 생성하는 식이죠.
왜 이 방식이 강력할까요? 첫째, 임의 길이의 음성 생성이 가능합니다.
미리 길이를 정하지 않아도 되고, 특수 토큰(예: <EOS>)이 나올 때까지 계속 생성하면 됩니다. 둘째, 조건부 생성이 자연스럽습니다.
텍스트 프롬프트나 화자 ID를 conditioning input으로 주면 그에 맞는 음성을 생성합니다. 셋째, 장기 의존성(long-range dependency)을 모델링할 수 있습니다.
문장 앞부분의 억양이 뒷부분에도 일관되게 유지되죠. Non-autoregressive 방식(예: FastSpeech)에서는 전체 음성 길이를 미리 예측하고 모든 프레임을 한 번에 생성합니다.
빠르지만 유연성이 떨어지고, 자연스러운 운율 변화를 만들기 어렵습니다. 반면 Autoregressive는 느리지만(sequential generation) 품질이 훨씬 높고, 다양한 스타일을 표현할 수 있습니다.
핵심 메커니즘은 세 가지입니다: (1) Causal Masking - attention에서 미래 토큰 보지 못하게 차단, (2) Sampling Strategy - greedy, top-k, nucleus sampling 등으로 다양성 조절, (3) Temperature Control - 낮으면 안정적, 높으면 창의적인 생성. 이 요소들을 조합해서 원하는 스타일의 음성을 만들어냅니다.
코드 예제
import torch
import torch.nn as nn
import torch.nn.functional as F
class AutoregressiveAudioGenerator(nn.Module):
"""Transformer 기반 자기회귀 음성 생성기"""
def __init__(self, audio_vocab_size=1024, d_model=512, nhead=8, num_layers=6):
super().__init__()
self.audio_vocab_size = audio_vocab_size
# 토큰 embedding
self.token_embedding = nn.Embedding(audio_vocab_size, d_model)
# Positional encoding
self.pos_encoding = nn.Parameter(torch.randn(1, 5000, d_model))
# Transformer decoder (causal attention)
decoder_layer = nn.TransformerDecoderLayer(d_model, nhead, dim_feedforward=2048, batch_first=True)
self.transformer = nn.TransformerDecoder(decoder_layer, num_layers)
# 다음 토큰 예측을 위한 출력층
self.output_proj = nn.Linear(d_model, audio_vocab_size)
def generate_square_subsequent_mask(self, sz):
"""Causal mask: 미래 토큰을 보지 못하게 함"""
mask = torch.triu(torch.ones(sz, sz), diagonal=1).bool()
return mask
def forward(self, input_tokens):
"""Training forward pass"""
seq_len = input_tokens.size(1)
# Embedding + positional encoding
x = self.token_embedding(input_tokens) + self.pos_encoding[:, :seq_len, :]
# Causal mask 생성
mask = self.generate_square_subsequent_mask(seq_len).to(input_tokens.device)
# Transformer decoding
# memory=x (self-attention처럼 사용)
output = self.transformer(x, x, tgt_mask=mask)
# 다음 토큰 확률 예측
logits = self.output_proj(output) # [B, seq_len, vocab_size]
return logits
@torch.no_grad()
def generate(self, prompt_tokens=None, max_length=500, temperature=1.0, top_k=50):
"""Autoregressive generation"""
self.eval()
# 시작 토큰 (또는 프롬프트)
if prompt_tokens is None:
# <BOS> 토큰 (예: 0번)
generated = torch.tensor([[0]], dtype=torch.long)
else:
generated = prompt_tokens.clone()
for _ in range(max_length):
# 현재까지 생성된 시퀀스로 다음 토큰 예측
logits = self.forward(generated) # [1, current_len, vocab_size]
# 마지막 위치의 logits만 사용
next_token_logits = logits[0, -1, :] / temperature
# Top-k sampling
if top_k > 0:
top_k_logits, top_k_indices = torch.topk(next_token_logits, top_k)
next_token_logits = torch.full_like(next_token_logits, float('-inf'))
next_token_logits[top_k_indices] = top_k_logits
# Softmax + 샘플링
probs = F.softmax(next_token_logits, dim=-1)
next_token = torch.multinomial(probs, 1).unsqueeze(0) # [1, 1]
# 생성된 토큰을 시퀀스에 추가 (autoregressive!)
generated = torch.cat([generated, next_token], dim=1)
# 종료 조건 (예: EOS 토큰이 생성되면)
if next_token.item() == 1: # 1번을 <EOS>라고 가정
break
return generated
# 사용 예시
model = AutoregressiveAudioGenerator(audio_vocab_size=1024, d_model=512, nhead=8, num_layers=6)
# 학습 예시 (teacher forcing)
input_tokens = torch.randint(0, 1024, (2, 100)) # [Batch=2, SeqLen=100]
logits = model(input_tokens)
print(f"출력 logits: {logits.shape}") # [2, 100, 1024]
# Autoregressive generation
print("\n=== Autoregressive Generation ===")
prompt = torch.tensor([[0, 123, 456]]) # 시작 프롬프트
generated_tokens = model.generate(prompt_tokens=prompt, max_length=50, temperature=0.9, top_k=50)
print(f"생성된 토큰 시퀀스: {generated_tokens.shape}")
print(f"처음 20개 토큰: {generated_tokens[0, :20].tolist()}")
# Temperature 효과 비교
print("\n=== Temperature 비교 ===")
for temp in [0.5, 1.0, 1.5]:
tokens = model.generate(max_length=30, temperature=temp, top_k=50)
print(f"Temp={temp}: {len(tokens[0])} 토큰 생성, 유니크 토큰 {len(set(tokens[0].tolist()))}개")
설명
이것이 하는 일: 위 코드는 Transformer 기반의 자기회귀 음성 토큰 생성기를 구현합니다. GPT의 텍스트 생성과 거의 동일한 구조지만, 출력이 문자가 아닌 Neural Codec의 음성 토큰이라는 점만 다릅니다.
실제 VALL-E, AudioLM 같은 최신 TTS 시스템의 핵심 엔진입니다. 첫 번째로, generate_square_subsequent_mask 함수를 보세요.
이것이 autoregressive의 핵심입니다. 상삼각 행렬로 마스크를 만들어서, 각 위치가 자기보다 뒤에 있는 토큰들을 볼 수 없게 합니다.
예를 들어 3번째 토큰을 생성할 때는 1, 2번 토큰만 보고 4, 5번은 못 보는 거죠. 이렇게 해야 "미래 정보를 안다"는 치팅 없이 진짜로 순차 생성하는 능력을 학습합니다.
그 다음으로, generate 함수의 루프를 자세히 봅시다. 이게 바로 autoregressive의 실제 작동 방식입니다.
처음에 generated = [0] (시작 토큰) 하나만 있습니다. 모델에 넣으면 다음 토큰 예측이 나오고 (예: 123), 그걸 추가해서 generated = [0, 123]이 됩니다.
다시 모델에 넣으면 그 다음 예측 (예: 456)이 나오고... 이런 식으로 반복합니다.
마치 사람이 "안" → "녕" → "하" → "세" → "요"를 순차적으로 말하는 것과 똑같습니다! Temperature 파라미터를 보세요.
logits / temperature를 하면 temperature가 낮을수록 확률 분포가 뾰족해집니다(argmax에 가까워짐). Temperature=0.5면 항상 가장 확률 높은 토큰만 선택해서 안정적이지만 단조롭습니다.
Temperature=1.5면 확률이 낮은 토큰도 자주 선택되어 다양하지만 가끔 이상한 소리가 나올 수 있죠. 실무에서는 0.8~1.0 사이를 많이 씁니다.
Top-k sampling은 또 다른 중요한 트릭입니다. 전체 1024개 토큰 중 확률이 높은 상위 50개만 후보로 두고 그 중에서 샘플링합니다.
이렇게 하면 확률이 극히 낮은 "이상한" 토큰들을 배제하면서도 어느 정도 다양성은 유지할 수 있습니다. Netflix 추천 시스템이 인기 영화 중에서 추천하는 것과 비슷한 전략이죠.
여러분이 이 메커니즘을 마스터하면 다음과 같은 고급 기능을 구현할 수 있습니다: (1) Voice Cloning - 몇 초의 프롬프트 토큰을 주고 그 스타일로 계속 생성, (2) Interactive TTS - 실시간으로 텍스트 입력받아 즉시 음성 토큰 생성 시작, (3) Emotion Control - temperature와 top-k를 동적으로 조절해서 감정 표현 강도 제어. 이것이 OpenAI의 Voice Engine이나 ElevenLabs의 기술 기반입니다.
실전 팁
💡 생성 속도를 높이려면 KV-cache를 사용하세요. 이전에 계산한 attention key/value를 재사용하면 50% 이상 속도 향상됩니다. Hugging Face transformers의 use_cache=True 옵션입니다.
💡 Multi-scale quantization과 함께 사용할 때는 coarse tokens을 먼저 전부 생성한 후, fine tokens를 생성하는 2-pass 방식이 효과적입니다. 1-pass로 모든 quantizer를 동시 생성하면 학습이 어렵습니다.
💡 Nucleus sampling (top-p)은 top-k보다 더 dynamic합니다. 확률 누적합이 p(예: 0.9)에 도달할 때까지의 토큰들만 사용하므로, 상황에 따라 후보 개수가 자동 조절됩니다.
💡 프로덕션에서는 beam search를 고려하세요. 여러 후보를 동시에 탐색해서 가장 좋은 시퀀스를 선택합니다. 느리지만 품질은 최상입니다.
💡 긴 음성 생성 시 "attention collapse" 문제가 발생할 수 있습니다(같은 토큰 반복). 이를 방지하려면 repetition penalty를 추가하거나, sliding window attention을 사용하세요.