이미지 로딩 중...
AI Generated
2025. 11. 19. · 4 Views
SNAC Codec으로 오디오를 토큰화하는 완벽 가이드
음성 AI 모델 학습을 위해 오디오를 토큰으로 변환하는 SNAC Codec의 모든 것을 알아봅니다. 24kHz 모델 설정부터 3-layer 코드 변환, 압축률 분석, 품질 검증까지 실전 예제와 함께 상세히 다룹니다.
목차
- SNAC 24kHz 모델 로드 및 설정
- 오디오를 3-layer SNAC codes로 변환
- 토큰 압축률 분석 (180k → 679 tokens)
- Decode 테스트로 품질 확인
- SNAC codes를 텍스트 토큰으로 변환
- 최종 학습 데이터 포맷 구성 (text + SNAC tokens)
1. SNAC 24kHz 모델 로드 및 설정
시작하며
여러분이 음성 합성 AI를 만들려고 할 때 이런 고민을 해본 적 있나요? "어떻게 오디오 파일을 AI가 이해할 수 있는 형태로 바꿀 수 있을까?" 텍스트는 쉽게 토큰으로 만들 수 있지만, 음성 데이터는 연속적인 파형이라서 막막하게 느껴집니다.
이런 문제는 실제 TTS(Text-to-Speech)나 음성 클로닝 프로젝트에서 가장 먼저 마주치는 장벽입니다. 오디오를 숫자의 연속으로 표현하되, 너무 많은 데이터가 되면 학습이 불가능하고, 너무 압축하면 품질이 떨어집니다.
바로 이럴 때 필요한 것이 SNAC(Scalable Neural Audio Codec)입니다. SNAC은 마치 이미지를 JPEG으로 압축하듯이, 오디오를 효율적으로 압축하면서도 품질을 유지하는 똑똑한 방법입니다.
24kHz 모델을 사용하면 사람의 목소리를 자연스럽게 표현할 수 있죠.
개요
간단히 말해서, SNAC은 오디오 파일을 AI가 학습할 수 있는 토큰으로 바꿔주는 신경망 기반 압축 도구입니다. 왜 이 도구가 필요한지 실무 관점에서 생각해볼까요?
LLM(Large Language Model)처럼 음성을 생성하는 AI를 만들려면, 오디오를 텍스트 토큰처럼 다룰 수 있어야 합니다. 예를 들어, "안녕하세요"라는 문장을 특정 목소리로 말하는 AI를 만든다면, 그 목소리의 특징을 숫자로 표현해야 하는데, 이때 SNAC이 매우 유용합니다.
기존에는 Mel-spectrogram이나 raw waveform을 사용했다면, 이제는 SNAC codes라는 이산적인(discrete) 토큰으로 변환할 수 있습니다. 이렇게 하면 텍스트 생성 모델과 같은 방식으로 음성을 생성할 수 있죠.
SNAC의 핵심 특징은 크게 세 가지입니다. 첫째, 계층적 구조(hierarchical)로 음성 정보를 저장하여 중요한 정보는 상위 레이어에, 세부 정보는 하위 레이어에 담습니다.
둘째, 압축률이 매우 뛰어나서 180,000개의 오디오 샘플을 679개의 토큰으로 줄일 수 있습니다. 셋째, 품질 손실이 거의 없어 원본과 거의 구분할 수 없는 수준으로 복원됩니다.
이러한 특징들이 음성 AI의 학습 효율과 품질을 동시에 높여주기 때문에 중요합니다.
코드 예제
import torch
from snac import SNAC
# SNAC 모델 로드 - 24kHz 샘플링 레이트 사용
# hubertsiuzdak/snac_24khz는 사람 목소리에 최적화된 모델입니다
model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").eval().cuda()
# 모델을 평가 모드로 전환 (학습하지 않고 추론만 수행)
# CUDA GPU 사용으로 빠른 처리 속도 확보
with torch.inference_mode():
# 여기서 실제 오디오 변환 작업을 수행합니다
pass
print(f"SNAC 모델 로드 완료!")
print(f"샘플링 레이트: 24kHz")
설명
이것이 하는 일: SNAC 모델을 메모리에 로드하고 GPU에서 실행할 수 있도록 준비하는 과정입니다. 마치 게임을 하기 전에 게임을 설치하고 실행하는 것처럼, 오디오를 처리하기 위한 AI 도구를 준비하는 단계죠.
첫 번째로, SNAC.from_pretrained("hubertsiuzdak/snac_24khz")는 사전에 학습된 SNAC 모델을 다운로드하고 로드합니다. 왜 24kHz일까요?
사람의 목소리는 대부분 12kHz 이하의 주파수를 사용하는데, 나이키스트 정리에 따라 12kHz를 제대로 표현하려면 최소 24kHz 샘플링이 필요합니다. 즉, 전화 통화 품질(8kHz)보다 훨씬 좋고, CD 음질(44.1kHz)보다는 가벼운 적절한 균형점입니다.
그 다음으로, .eval().cuda()가 실행되면서 모델을 평가 모드로 전환하고 GPU로 옮깁니다. eval() 모드는 dropout이나 batch normalization 같은 학습용 기능을 끄고, cuda()는 연산을 GPU에서 수행하도록 합니다.
CPU로도 가능하지만 GPU를 사용하면 수십 배 빠릅니다. torch.inference_mode()는 PyTorch에게 "우리는 지금 추론만 할 거야, 학습은 안 해"라고 알려주는 컨텍스트 매니저입니다.
이렇게 하면 메모리 사용량이 줄어들고 속도가 빨라집니다. 마치 책을 읽기만 하고 메모는 하지 않는 것처럼, 역전파(backpropagation)를 위한 정보를 저장하지 않기 때문입니다.
여러분이 이 코드를 사용하면 몇 초 만에 강력한 오디오 압축 엔진을 손에 넣을 수 있습니다. 수백만 개의 파라미터로 학습된 모델을 직접 만들 필요 없이, 이미 검증된 모델을 바로 활용할 수 있다는 것이 가장 큰 장점입니다.
또한 GPU 가속으로 실시간에 가까운 처리 속도를 얻을 수 있어, 대량의 오디오 데이터를 처리하는 프로젝트에 적합합니다.
실전 팁
💡 GPU 메모리가 부족하다면 .cuda() 대신 .cpu()를 사용하세요. 속도는 느리지만 모든 환경에서 작동합니다.
💡 여러 오디오 파일을 처리할 때는 모델을 한 번만 로드하고 재사용하세요. 매번 로드하면 시간이 오래 걸립니다.
💡 torch.inference_mode() 대신 torch.no_grad()를 써도 되지만, inference_mode가 더 빠르고 메모리 효율적입니다.
💡 모델 로드가 느리다면 첫 실행 시 모델이 캐시에 다운로드되는 중입니다. 이후에는 빠르게 로드됩니다.
💡 24kHz가 아닌 다른 샘플링 레이트가 필요하다면 SNAC 저장소에서 다른 모델(16kHz, 32kHz)을 확인해보세요.
2. 오디오를 3-layer SNAC codes로 변환
시작하며
여러분이 10초짜리 음성 파일을 AI에게 학습시키려고 할 때, 24,000Hz × 10초 = 240,000개의 숫자를 전부 처리해야 한다면 얼마나 비효율적일까요? GPU 메모리도 부족하고 학습 시간도 엄청나게 길어질 겁니다.
이런 문제는 음성 생성 모델을 실제로 학습시킬 때 가장 큰 장애물입니다. 텍스트는 "안녕하세요"가 5개 토큰이면 끝이지만, 같은 말을 음성으로 하면 수만 개의 샘플이 필요하죠.
이 격차를 줄이지 않으면 실용적인 모델을 만들 수 없습니다. 바로 이럴 때 SNAC의 계층적 인코딩이 빛을 발합니다.
음성을 세 개의 레이어로 나누어, 중요한 정보(발음, 음높이)는 첫 번째 레이어에, 세부적인 음색은 나중 레이어에 담아서 효율적으로 압축합니다. 마치 사진을 저장할 때 윤곽선은 고화질로, 배경은 저화질로 저장하는 것과 비슷합니다.
개요
간단히 말해서, 이 과정은 연속적인 오디오 파형을 세 단계의 이산적인 코드 숫자들로 변환하는 작업입니다. 왜 세 개의 레이어로 나누는지 실무 관점에서 생각해볼까요?
음성에는 중요도가 다른 여러 정보가 섞여 있습니다. "무엇을 말하는지"(내용)가 가장 중요하고, "어떤 목소리인지"(화자 특성)가 그다음이며, "배경 잡음"이나 "미세한 떨림" 같은 건 덜 중요합니다.
예를 들어, 음성 클로닝 모델을 만든다면 첫 번째 레이어만으로도 대략적인 발음을 재현할 수 있고, 세 레이어를 모두 사용하면 원본과 거의 구분할 수 없는 품질을 얻습니다. 기존에는 모든 정보를 동일한 중요도로 처리했다면, 이제는 계층적으로 처리하여 필요에 따라 품질과 효율을 조절할 수 있습니다.
낮은 품질이 필요하면 첫 번째 레이어만, 높은 품질이 필요하면 세 레이어 모두 사용하면 됩니다. SNAC 인코딩의 핵심 특징은 이렇습니다.
첫째, VQ(Vector Quantization)를 사용하여 연속적인 값을 코드북의 인덱스로 변환합니다. 둘째, 각 레이어는 서로 다른 시간 해상도를 가져서 레이어 0이 가장 촘촘하고 레이어 2가 가장 성깁니다.
셋째, 각 레이어는 4096개의 가능한 코드를 가지므로 충분한 표현력을 확보합니다. 이러한 특징들이 고품질 압축과 빠른 학습을 동시에 가능하게 만듭니다.
코드 예제
import torchaudio
# 오디오 파일 로드 (24kHz로 리샘플링)
audio_path = "sample_voice.wav"
audio, sr = torchaudio.load(audio_path)
# 모노 채널로 변환하고 24kHz로 리샘플링
if audio.shape[0] > 1:
audio = audio.mean(dim=0, keepdim=True)
if sr != 24000:
audio = torchaudio.functional.resample(audio, sr, 24000)
# GPU로 전송 및 배치 차원 추가
audio = audio.cuda().unsqueeze(0)
# SNAC 인코딩: 오디오 -> 3-layer codes
codes = model.encode(audio) # [레이어0, 레이어1, 레이어2]
print(f"원본 오디오 길이: {audio.shape[-1]} 샘플")
print(f"Layer 0 codes: {codes[0].shape}")
print(f"Layer 1 codes: {codes[1].shape}")
print(f"Layer 2 codes: {codes[2].shape}")
설명
이것이 하는 일: 음성 파일을 읽어서 SNAC 모델이 이해할 수 있는 형태로 전처리한 뒤, 세 개의 레이어로 구성된 토큰 시퀀스로 변환합니다. 마치 사진을 픽셀로 분해하듯이, 음성을 숫자 코드들로 분해하는 과정입니다.
첫 번째로, torchaudio.load()로 오디오 파일을 읽습니다. 이때 스테레오(2채널) 오디오라면 .mean(dim=0)으로 모노(1채널)로 바꿉니다.
왜냐하면 음성 인식이나 합성에서는 좌우 채널 구분이 중요하지 않고, 단일 채널이 훨씬 처리하기 쉽기 때문입니다. 또한 샘플링 레이트가 24kHz가 아니라면 리샘플링을 수행합니다.
48kHz 오디오를 24kHz로 다운샘플링하거나, 16kHz 오디오를 24kHz로 업샘플링하는 식이죠. 그 다음으로, .cuda().unsqueeze(0)으로 오디오를 GPU로 보내고 배치 차원을 추가합니다.
PyTorch 모델은 보통 [batch, channel, time] 형태를 기대하므로, 단일 오디오도 배치 크기 1로 감싸줘야 합니다. 이는 여러 오디오를 동시에 처리할 때도 같은 코드를 사용할 수 있게 해줍니다.
model.encode(audio)가 실행되면 마법이 일어납니다. 내부적으로 신경망이 오디오를 분석하여 각 시점의 음향 특징을 추출하고, 이를 가장 가까운 코드북 벡터의 인덱스로 변환합니다.
결과는 세 개의 텐서 리스트인데, codes[0]은 가장 세밀한 정보를, codes[2]는 가장 추상적인 정보를 담습니다. 예를 들어 7.5초짜리 오디오라면 레이어 0은 약 600개, 레이어 1은 약 75개, 레이어 2는 약 19개의 코드를 생성합니다.
여러분이 이 코드를 사용하면 어떤 오디오 파일이든 표준화된 토큰 형태로 바꿀 수 있습니다. 이렇게 변환된 코드는 텍스트 토큰처럼 다룰 수 있어서, Transformer 같은 언어 모델 구조로 음성을 생성하거나 편집할 수 있습니다.
또한 원본 오디오보다 수백 배 작은 크기로 압축되어, 대규모 데이터셋 학습이 가능해집니다. 디버깅할 때도 숫자 코드들을 직접 확인할 수 있어서 모델이 무엇을 학습하는지 이해하기 쉽습니다.
실전 팁
💡 오디오가 너무 길면 GPU 메모리 부족이 발생할 수 있습니다. 10초 이상이라면 청크로 나눠서 처리하세요.
💡 배치 처리를 하려면 모든 오디오를 같은 길이로 패딩해야 합니다. torch.nn.functional.pad()를 사용하세요.
💡 codes는 리스트가 아니라 튜플로 반환됩니다. 인덱싱할 때 주의하세요.
💡 샘플링 레이트가 다른 오디오를 섞으면 품질이 나빠집니다. 항상 24kHz로 통일하세요.
💡 CPU로 처리하려면 .cuda() 대신 .cpu()를 사용하고, 배치 크기를 1로 유지하세요.
3. 토큰 압축률 분석 (180k → 679 tokens)
시작하며
여러분이 AI 모델을 학습시킬 때 가장 신경 쓰이는 게 뭔가요? 바로 "얼마나 많은 데이터를 메모리에 올릴 수 있는가"입니다.
180,000개의 숫자를 처리하는 것과 679개를 처리하는 것은 하늘과 땅 차이죠. 이런 문제는 특히 긴 음성을 다룰 때 심각합니다.
1분짜리 음성이면 1,440,000개의 샘플인데, 이걸 전부 GPU에 올리려면 엄청난 메모리가 필요합니다. 배치 크기를 줄이면 학습이 불안정해지고, 메모리를 늘리자니 비용이 너무 많이 듭니다.
바로 이럴 때 SNAC의 압축률이 게임 체인저가 됩니다. 265배 압축이라는 건 단순히 "작아졌다"가 아니라, "이제 실용적으로 학습할 수 있다"는 의미입니다.
같은 GPU로 265배 많은 음성을 동시에 처리할 수 있으니까요.
개요
간단히 말해서, 압축률 분석은 원본 오디오와 압축된 토큰의 데이터 양을 비교하여 얼마나 효율적인지 측정하는 과정입니다. 왜 압축률을 정확히 알아야 할까요?
실무에서는 이 수치가 학습 가능 여부를 결정하기 때문입니다. 예를 들어, GPU 메모리가 16GB라면 배치 크기와 시퀀스 길이의 곱이 메모리 한계를 넘으면 안 됩니다.
180k 토큰이라면 배치 크기 4도 어렵지만, 679 토큰이라면 배치 크기 100도 가능합니다. 이는 학습 속도와 안정성에 직접적인 영향을 미칩니다.
기존에는 압축 코덱의 효율을 비트레이트(kbps)로 표현했다면, 이제는 토큰 개수 비율로 표현하여 AI 모델 관점에서 직관적으로 이해할 수 있습니다. MP3가 10:1 압축이라고 해도 AI 학습에는 여전히 너무 많은 데이터지만, SNAC은 265:1이라서 현실적입니다.
압축률 계산의 핵심 특징은 이렇습니다. 첫째, 레이어별로 다른 다운샘플링 비율을 가지므로 각 레이어의 토큰 수를 합산해야 정확합니다.
둘째, 샘플 수와 토큰 수는 샘플링 레이트에 따라 달라지므로 항상 같은 기준으로 비교해야 합니다. 셋째, 압축률이 높을수록 좋지만 일정 수준 이상이면 품질이 떨어지므로 균형점을 찾아야 합니다.
이러한 분석이 모델 설계와 하이퍼파라미터 설정의 기준이 됩니다.
코드 예제
# 7.5초 오디오의 압축률 계산
audio_duration = 7.5 # 초
sample_rate = 24000
total_samples = int(audio_duration * sample_rate)
# 각 레이어의 토큰 개수 (실제 인코딩 결과)
layer0_tokens = codes[0].shape[-1] # 예: 600
layer1_tokens = codes[1].shape[-1] # 예: 75
layer2_tokens = codes[2].shape[-1] # 예: 19
total_tokens = layer0_tokens + layer1_tokens + layer2_tokens
# 압축률 계산
compression_ratio = total_samples / total_tokens
print(f"원본 샘플 수: {total_samples:,}")
print(f"압축 후 토큰 수: {total_tokens}")
print(f"압축률: {compression_ratio:.1f}:1")
print(f"데이터 크기 감소: {(1 - total_tokens/total_samples)*100:.2f}%")
설명
이것이 하는 일: 원본 오디오의 샘플 개수와 SNAC으로 압축한 토큰 개수를 비교하여 압축 효율을 정량적으로 측정합니다. 마치 100MB 파일을 ZIP으로 압축했을 때 얼마나 작아졌는지 확인하는 것과 같습니다.
첫 번째로, 원본 샘플 수를 계산합니다. audio_duration × sample_rate는 기본 공식으로, 7.5초 × 24,000Hz = 180,000 샘플입니다.
이 180,000개의 부동소수점 숫자가 원본 오디오를 표현하는 데이터입니다. 각 샘플은 보통 16비트 또는 32비트로 저장되므로, 실제 파일 크기는 360KB~720KB 정도 됩니다.
그 다음으로, 세 레이어의 토큰 개수를 합산합니다. codes[0].shape[-1]은 시간 축의 길이를 의미하는데, SNAC은 레이어마다 다른 다운샘플링을 적용합니다.
보통 레이어 0은 30ms마다 1개, 레이어 1은 120ms마다 1개, 레이어 2는 480ms마다 1개의 토큰을 생성합니다. 7.5초라면 각각 약 250, 62, 16개가 나오고, 합하면 약 328개입니다.
(실제 예시는 600+75+19=694개로 모델 버전에 따라 다를 수 있습니다) 압축률 계산은 간단한 나눗셈입니다. total_samples / total_tokens를 하면 180,000 / 679 ≈ 265가 나옵니다.
이는 "265개의 샘플을 1개의 토큰으로 표현한다"는 뜻입니다. 퍼센트로 표현하면 99.6% 감소로, 거의 모든 중복과 불필요한 정보를 제거한 셈입니다.
여러분이 이 분석을 하면 모델 설계에 구체적인 지표를 얻을 수 있습니다. 예를 들어, GPT 모델이 최대 2048 토큰을 처리할 수 있다면, SNAC으로는 약 18초의 음성을 한 번에 처리할 수 있다는 계산이 나옵니다.
또한 1000시간의 음성 데이터셋이라면, 원본은 86GB지만 SNAC 토큰으로는 325MB만 필요하다는 것도 알 수 있죠. 이런 수치가 서버 비용, 학습 시간, 배치 크기 설정의 기준이 됩니다.
실전 팁
💡 압축률은 오디오 길이와 무관하게 일정합니다. 1초든 10초든 비율은 같으므로 짧은 샘플로 테스트하세요.
💡 각 레이어의 비중을 분석하면 어느 레이어가 중요한지 알 수 있습니다. 보통 레이어 0이 전체의 80% 이상을 차지합니다.
💡 데이터셋 전체의 평균 압축률을 계산하면 저장 공간과 학습 시간을 정확히 예측할 수 있습니다.
💡 압축률이 예상보다 낮다면 오디오에 무음 구간이 많거나, 샘플링 레이트가 잘못되었을 수 있습니다.
💡 토큰 수는 정수이므로 매우 짧은 오디오(<1초)는 압축률이 비효율적입니다. 최소 3초 이상을 권장합니다.
4. Decode 테스트로 품질 확인
시작하며
여러분이 파일을 ZIP으로 압축했는데, 압축을 풀었을 때 원본과 다르다면? 그건 재앙이죠.
음성 압축도 마찬가지입니다. 아무리 압축률이 좋아도 품질이 나쁘면 쓸모가 없습니다.
이런 문제는 손실 압축(lossy compression)을 사용할 때 항상 따라다닙니다. MP3나 JPEG처럼 일부 정보를 버리면서 압축하기 때문에, 원본과 완전히 같을 수는 없습니다.
하지만 사람이 차이를 느낄 수 없다면 실용적으로는 문제없죠. SNAC도 손실 압축이므로 품질 검증이 필수입니다.
바로 이럴 때 필요한 것이 디코드 테스트입니다. 압축한 토큰을 다시 오디오로 복원해서 원본과 비교하면, 얼마나 정보가 손실되었는지 귀로 직접 확인할 수 있습니다.
전문적으로는 MOS(Mean Opinion Score)나 PESQ 같은 지표를 사용하지만, 일단 들어보는 게 가장 직관적입니다.
개요
간단히 말해서, 디코드 테스트는 SNAC 토큰을 다시 오디오 파형으로 변환하여 원본과 비교하는 품질 검증 과정입니다. 왜 이 테스트가 중요한지 실무 관점에서 생각해볼까요?
음성 AI를 만들 때 우리의 목표는 "토큰 생성"이 아니라 "자연스러운 음성 생성"입니다. 예를 들어, TTS 모델이 SNAC 토큰을 생성했다면, 최종 사용자는 그 토큰을 실제 음성으로 들어야 합니다.
만약 디코드 품질이 나쁘면 모델 성능이 아무리 좋아도 최종 결과물은 로봇 같은 소리가 나겠죠. 기존에는 품질을 확인하려면 복잡한 metric을 계산해야 했다면, 이제는 단순히 디코드해서 들어보고 파일로 저장하여 확인할 수 있습니다.
원본과 복원본을 A/B 테스트하듯이 비교하면 됩니다. SNAC 디코딩의 핵심 특징은 이렇습니다.
첫째, 세 레이어의 코드를 모두 사용하여 복원하므로 계층적 정보가 결합됩니다. 둘째, 디코더는 인코더와 쌍을 이루는 신경망으로, 코드북 벡터를 다시 파형으로 변환합니다.
셋째, 복원된 오디오는 원본과 위상은 다를 수 있지만 인지적으로는 거의 같습니다. 이러한 특성 덕분에 사람 귀로는 차이를 거의 느낄 수 없는 수준의 품질을 얻을 수 있습니다.
코드 예제
# SNAC codes를 다시 오디오로 복원
with torch.inference_mode():
audio_reconstructed = model.decode(codes)
# GPU에서 CPU로 이동하고 넘파이 배열로 변환
audio_reconstructed = audio_reconstructed.squeeze(0).cpu()
# 원본과 비교를 위해 파일로 저장
import torchaudio
torchaudio.save(
"reconstructed.wav",
audio_reconstructed,
sample_rate=24000
)
# 간단한 품질 지표: MSE (낮을수록 좋음)
mse = torch.mean((audio.cpu() - audio_reconstructed) ** 2).item()
print(f"복원 완료! MSE: {mse:.6f}")
print(f"파일 저장: reconstructed.wav")
설명
이것이 하는 일: 압축된 SNAC 토큰을 신경망 디코더에 통과시켜 다시 연속적인 오디오 파형으로 재구성하고, 원본과 비교하여 품질을 평가합니다. 마치 JPEG 이미지를 압축 해제하여 원본 사진과 비교하는 것과 같습니다.
첫 번째로, model.decode(codes)가 핵심 작업을 수행합니다. 내부적으로는 세 레이어의 코드(각각 정수 인덱스)를 받아서 코드북에서 해당하는 벡터를 찾아냅니다.
예를 들어 코드가 [42, 137, 9]라면 코드북의 42번, 137번, 9번 벡터를 가져오는 식이죠. 그런 다음 이 벡터들을 디코더 신경망에 통과시켜 파형으로 변환합니다.
이 과정은 인코딩의 정확한 역과정입니다. 그 다음으로, .squeeze(0).cpu()로 배치 차원을 제거하고 CPU 메모리로 옮깁니다.
GPU에 있는 텐서는 파일로 저장할 수 없으므로 CPU로 이동이 필수입니다. squeeze(0)은 [1, 1, 180000] 형태를 [1, 180000]으로 만들어, torchaudio가 기대하는 [channel, time] 형태로 맞춥니다.
torchaudio.save()로 WAV 파일을 저장하면 실제로 들어볼 수 있습니다. 원본 "sample_voice.wav"와 복원본 "reconstructed.wav"를 번갈아 재생해보면 차이를 확인할 수 있습니다.
대부분의 경우 거의 구분할 수 없고, 조금 들리는 차이는 약간의 배경 노이즈 감소 정도입니다. 실제로 SNAC은 노이즈 제거 효과도 약간 있습니다.
MSE(Mean Squared Error) 계산은 정량적 지표를 제공합니다. 두 파형의 차이를 제곱하여 평균 낸 값으로, 0에 가까울수록 완벽한 복원입니다.
보통 0.0010.01 정도 나오는데, 이는 각 샘플이 평균적으로 0.030.1 정도 차이 난다는 뜻입니다. 오디오는 보통 -1~1 범위이므로 이 정도 오차는 귀로 거의 감지할 수 없습니다.
여러분이 이 테스트를 수행하면 SNAC을 프로젝트에 적용할지 확신을 가질 수 있습니다. 실제로 들어보고 "이 정도 품질이면 괜찮아"라고 판단할 수 있으니까요.
또한 여러 오디오 샘플로 테스트하여 어떤 종류의 소리(목소리, 음악, 환경음)에서 잘 작동하는지 파악할 수 있습니다. 품질 문제가 발견되면 더 높은 비트레이트 모델을 사용하거나, 전처리를 개선하는 등의 조치를 취할 수 있죠.
실전 팁
💡 원본과 복원본을 동시에 재생하며 비교하면 차이를 더 잘 찾을 수 있습니다. Audacity 같은 도구를 사용하세요.
💡 MSE 외에도 SI-SNR, PESQ 같은 전문 지표를 사용하면 더 정확한 평가가 가능합니다. pesq 라이브러리를 설치하세요.
💡 복원 품질은 오디오 종류에 따라 다릅니다. 깨끗한 목소리는 잘 되지만, 노이즈가 많은 오디오는 차이가 날 수 있습니다.
💡 디코딩은 인코딩보다 약간 더 빠릅니다. 배치 처리를 하면 실시간보다 100배 이상 빠르게 처리할 수 있습니다.
💡 복원된 오디오를 다시 인코딩하면 토큰이 거의 같게 나옵니다. 이를 round-trip test라고 하며, 일관성을 확인할 수 있습니다.
5. SNAC codes를 텍스트 토큰으로 변환
시작하며
여러분이 LLM에게 "안녕하세요"를 학습시킬 때는 간단합니다. 텍스트를 토큰화하면 끝이죠.
하지만 "안녕하세요"를 특정 목소리로 말하는 것을 학습시키려면? SNAC 코드가 [42, 137, 9, ...]처럼 3차원 텐서 형태인데, 이걸 어떻게 텍스트 토큰과 섞어야 할까요?
이런 문제는 멀티모달 AI를 만들 때 핵심 난제입니다. GPT는 1차원 토큰 시퀀스만 처리할 수 있는데, SNAC은 3개 레이어로 나뉘어 있어서 직접 넣을 수 없습니다.
"어떻게 평평하게(flatten) 만들까?" "레이어 정보는 어떻게 보존할까?"를 고민해야 합니다. 바로 이럴 때 필요한 것이 특수 토큰을 사용한 평탄화(flattening) 전략입니다.
각 레이어의 코드를 구분자로 감싸서 한 줄로 펼치면, Transformer가 이해할 수 있는 형태가 됩니다. 마치 여러 책을 한 줄로 세울 때 책갈피로 구분하는 것처럼요.
개요
간단히 말해서, 이 과정은 3개 레이어의 2차원 SNAC 코드를 특수 토큰과 함께 1차원 시퀀스로 재구성하는 작업입니다. 왜 이 변환이 필요한지 실무 관점에서 생각해볼까요?
음성 생성 LLM을 만들려면 "텍스트 입력 → SNAC 토큰 출력" 형태로 학습해야 합니다. 예를 들어, "오늘 날씨는 어때요?" 라는 텍스트를 입력하면 모델이 SNAC 토큰 시퀀스를 생성하고, 이를 디코드하면 음성이 나오는 식입니다.
이때 텍스트 토큰(1, 2, 3, ...)과 SNAC 토큰(42, 137, 9, ...)을 같은 vocabulary에서 관리하려면 구분이 필요합니다. 기존에는 별도의 모델로 처리했다면(텍스트 인코더 + 오디오 디코더), 이제는 단일 Transformer로 end-to-end 학습이 가능합니다.
텍스트와 음성을 모두 토큰으로 보는 거죠. SNAC 토큰화의 핵심 특징은 이렇습니다.
첫째, <snac_0>, <snac_1>, <snac_2> 같은 특수 토큰으로 레이어를 구분하여 모델이 문맥을 이해하도록 돕습니다. 둘째, 오프셋(offset)을 사용하여 SNAC 코드와 텍스트 토큰의 vocabulary 범위가 겹치지 않게 합니다.
셋째, 시간 순서를 유지하여 autoregressive 생성이 가능하도록 합니다. 이러한 설계가 GPT 스타일의 음성 생성 모델을 가능하게 만듭니다.
코드 예제
# SNAC codes를 텍스트 토큰 형태로 변환
# 각 레이어를 구분하는 특수 토큰 사용
SNAC_OFFSET = 50000 # 텍스트 vocab과 충돌 방지
LAYER_MARKERS = {
0: "<snac_0>",
1: "<snac_1>",
2: "<snac_2>"
}
def snac_to_tokens(codes):
"""SNAC codes를 1D 토큰 시퀀스로 변환"""
token_sequence = []
for layer_idx, layer_codes in enumerate(codes):
# 레이어 시작 마커 추가
token_sequence.append(f"<layer_{layer_idx}>")
# 코드를 오프셋 적용하여 추가
for code in layer_codes.flatten().tolist():
token_sequence.append(SNAC_OFFSET + code)
return token_sequence
tokens = snac_to_tokens(codes)
print(f"총 토큰 개수: {len(tokens)}")
print(f"처음 20개: {tokens[:20]}")
설명
이것이 하는 일: 계층적으로 구조화된 SNAC 코드를 Transformer 모델이 처리할 수 있는 평평한 토큰 리스트로 변환합니다. 마치 3층 건물을 1층으로 펼쳐서 길게 늘어놓는 것과 비슷합니다.
첫 번째로, SNAC_OFFSET = 50000을 설정합니다. 이게 왜 필요할까요?
텍스트 tokenizer는 보통 050000 범위의 ID를 사용합니다(예: "안녕" = 1234, "하세요" = 5678). SNAC 코드도 04095 범위이므로, 그대로 사용하면 텍스트와 겹칩니다.
그래서 SNAC 코드에는 모두 50000을 더해서 50000~54095 범위로 만들어 충돌을 피합니다. 이렇게 하면 모델이 "이건 텍스트 토큰", "이건 SNAC 토큰"을 구분할 수 있습니다.
그 다음으로, 각 레이어를 순회하면서 <layer_0>, <layer_1>, <layer_2> 마커를 추가합니다. 이 특수 토큰들은 vocabulary에 미리 등록되어 있어야 하며, 보통 50257, 50258, 50259 같은 ID를 받습니다.
왜 필요할까요? 모델이 "지금부터 레이어 1 정보가 나온다"는 힌트를 받으면, 학습할 때 레이어별 패턴을 더 잘 이해할 수 있기 때문입니다.
layer_codes.flatten().tolist()는 2D 텐서를 1D 리스트로 바꿉니다. 예를 들어 레이어 0 코드가 [[42, 137], [9, 203]] 형태라면, [42, 137, 9, 203]으로 펼쳐집니다.
그런 다음 각 코드에 오프셋을 더해 [50042, 50137, 50009, 50203]이 됩니다. 이 과정을 세 레이어 모두에 반복하면 최종적으로 수백 개의 정수 리스트가 만들어집니다.
최종 token_sequence는 이런 모습입니다: ["<layer_0>", 50042, 50137, ..., "<layer_1>", 50315, ..., "<layer_2>", 50089, ...]. 이제 이걸 GPT에 입력하면 됩니다.
여러분이 이 변환을 사용하면 텍스트와 음성을 동일한 프레임워크에서 다룰 수 있습니다. 예를 들어, "안녕하세요" 텍스트 토큰 뒤에 SNAC 토큰을 붙여서 "안녕하세요 [음성]" 형태로 학습시킬 수 있습니다.
이는 ChatGPT가 텍스트와 이미지를 함께 처리하는 것과 같은 원리입니다. 또한 이 형태로 데이터를 저장하면 JSON이나 텍스트 파일로 관리하기 쉬워서 전처리 파이프라인이 간단해집니다.
실전 팁
💡 SNAC_OFFSET은 텍스트 vocab 크기보다 충분히 커야 합니다. GPT-2는 50257이므로 50000이 안전합니다.
💡 특수 토큰은 tokenizer.add_special_tokens()로 미리 등록해야 합니다. 안 그러면 UNK 토큰으로 처리됩니다.
💡 레이어 순서를 바꿔보는 실험도 가치 있습니다. 때로는 레이어 2부터 생성하는 게 더 나을 수 있습니다.
💡 토큰 시퀀스가 너무 길면 truncate하거나 sliding window를 사용하세요. 대부분 모델은 512~2048 토큰이 한계입니다.
💡 디버깅 시 토큰을 다시 SNAC 코드로 역변환하는 함수도 만들어두면 편합니다. 대칭성을 확인할 수 있습니다.
6. 최종 학습 데이터 포맷 구성 (text + SNAC tokens)
시작하며
여러분이 음성 AI를 학습시키려고 데이터를 준비했는데, "이 데이터를 어떤 형식으로 저장하지?" 하고 막막한 적 있나요? CSV?
JSON? 아니면 바이너리?
텍스트와 오디오 토큰을 어떻게 한 파일에 넣을까요? 이런 문제는 실제 학습 파이프라인을 구축할 때 반드시 해결해야 합니다.
데이터 형식이 잘못되면 DataLoader에서 에러가 나거나, 메모리가 부족하거나, 학습 속도가 느려집니다. 또한 나중에 모델을 개선할 때 데이터를 다시 만들어야 할 수도 있죠.
바로 이럴 때 필요한 것이 명확하고 확장 가능한 데이터 포맷입니다. 텍스트, SNAC 토큰, 메타데이터를 구조화된 형태로 저장하면, 나중에 데이터셋을 쉽게 수정하고 버전 관리할 수 있습니다.
JSON Lines 형식이 이런 용도에 딱 맞습니다.
개요
간단히 말해서, 이 단계는 텍스트 transcript와 SNAC 토큰을 하나의 학습 샘플로 결합하여 표준 형식으로 저장하는 작업입니다. 왜 이 포맷이 중요한지 실무 관점에서 생각해볼까요?
딥러닝 학습은 보통 PyTorch Dataset 클래스로 데이터를 읽습니다. 예를 들어, HuggingFace Trainer나 PyTorch Lightning을 사용한다면, 각 샘플이 딕셔너리 형태로 {"input_ids": [...], "labels": [...]}처럼 구성되어야 합니다.
텍스트 토큰을 input으로, SNAC 토큰을 label로 하면 "이 문장을 이렇게 발음해"를 학습하는 모델이 됩니다. 기존에는 텍스트와 오디오를 별도 파일로 관리했다면(text.txt, audio.wav), 이제는 모든 정보를 하나의 JSONL 파일에 담아서 관리가 간편합니다.
버전 관리도 쉽고, 샘플을 추가/제거하기도 편하죠. 학습 데이터 포맷의 핵심 특징은 이렇습니다.
첫째, 각 샘플이 독립적인 JSON 객체로, 병렬 처리와 셔플링이 쉽습니다. 둘째, 텍스트, 토큰, 메타데이터를 한 곳에 모아서 데이터 일관성을 보장합니다.
셋째, 사람이 읽을 수 있는 형식이라 디버깅과 검증이 간단합니다. 이러한 구조가 안정적이고 유지보수하기 쉬운 학습 파이프라인을 만들어줍니다.
코드 예제
import json
def create_training_sample(text, audio_path, snac_tokens):
"""학습용 데이터 샘플 생성"""
# 텍스트를 토큰화 (예시: 간단한 tokenizer 사용)
# 실제로는 transformers.AutoTokenizer 사용
text_tokens = [ord(c) for c in text] # 예시용 간단 변환
sample = {
"id": audio_path.split("/")[-1].replace(".wav", ""),
"text": text,
"text_tokens": text_tokens,
"snac_tokens": snac_tokens,
"total_tokens": len(text_tokens) + len(snac_tokens),
"audio_duration": len(snac_tokens) / 265 * 180000 / 24000 # 역계산
}
return sample
# 학습 데이터 생성 및 저장
sample = create_training_sample(
text="오늘 날씨는 정말 좋네요",
audio_path="sample_voice.wav",
snac_tokens=tokens
)
# JSONL 형식으로 저장 (한 줄에 하나의 JSON)
with open("training_data.jsonl", "a") as f:
f.write(json.dumps(sample, ensure_ascii=False) + "\n")
print(f"학습 샘플 저장 완료!")
print(f"텍스트 토큰: {len(sample['text_tokens'])}개")
print(f"SNAC 토큰: {len(sample['snac_tokens'])}개")
설명
이것이 하는 일: 원본 텍스트, 텍스트 토큰, SNAC 토큰, 메타데이터를 하나의 딕셔너리로 묶어서 JSONL 파일에 저장합니다. 마치 엑셀 시트의 한 행에 모든 정보를 기록하는 것과 비슷합니다.
첫 번째로, create_training_sample() 함수가 데이터를 구조화합니다. id 필드는 샘플을 고유하게 식별하는 키로, 보통 파일명에서 추출합니다.
text는 원본 텍스트를 그대로 저장하여 나중에 검증할 때 사용합니다. text_tokens는 실제로는 transformers 라이브러리의 tokenizer를 사용해야 하지만, 예시에서는 간단히 ASCII 코드로 변환했습니다.
실제 프로젝트에서는 tokenizer.encode(text)를 사용하세요. 그 다음으로, snac_tokens에는 앞서 변환한 1D 토큰 시퀀스를 저장합니다.
이 리스트에는 특수 토큰과 오프셋이 적용된 SNAC 코드가 모두 들어있습니다. total_tokens는 전체 시퀀스 길이로, 모델의 context window를 확인할 때 유용합니다.
예를 들어 GPT-2의 최대 길이가 1024인데 total_tokens이 2000이라면 이 샘플은 잘라야 한다는 걸 알 수 있죠. audio_duration은 역계산으로 추정합니다.
SNAC 토큰 개수에서 압축률(265)과 샘플링 레이트(24000)를 역으로 적용하면 원본 오디오 길이를 알 수 있습니다. 이 정보는 데이터셋 통계를 낼 때 유용합니다.
"평균 오디오 길이는 5.3초"같은 분석을 할 수 있으니까요. json.dumps(sample, ensure_ascii=False)는 딕셔너리를 JSON 문자열로 변환합니다.
ensure_ascii=False는 한글이 "\uXXXX"로 이스케이프되지 않고 그대로 저장되게 합니다. 그런 다음 \n을 붙여서 파일에 추가하면, JSONL(JSON Lines) 형식이 됩니다.
이 형식은 각 줄이 독립적인 JSON이라서, 대용량 파일도 스트리밍 방식으로 읽을 수 있습니다. 여러분이 이 포맷을 사용하면 학습 코드가 매우 간단해집니다.
PyTorch Dataset은 이렇게 작성할 수 있습니다: python class SpeechDataset(Dataset): def __init__(self, jsonl_path): with open(jsonl_path) as f: self.samples = [json.loads(line) for line in f] def __getitem__(self, idx): sample = self.samples[idx] return { "input_ids": sample["text_tokens"], "labels": sample["snac_tokens"] } 이렇게 5줄이면 데이터 로더가 완성됩니다. 또한 JSONL은 Git에서 diff가 잘 보여서, 데이터셋 변경 이력을 추적하기도 좋습니다.
샘플 하나를 수정하면 한 줄만 바뀌므로 리뷰도 쉽죠.
실전 팁
💡 실제 프로젝트에서는 transformers.AutoTokenizer를 사용하여 텍스트를 토큰화하세요. BERT나 GPT tokenizer가 표준입니다.
💡 JSONL 파일이 너무 커지면(>1GB) Apache Parquet 형식으로 전환하세요. 읽기 속도가 10배 이상 빨라집니다.
💡 메타데이터에 화자 ID, 감정 레이블 등을 추가하면 조건부 생성(conditional generation)이 가능합니다.
💡 데이터를 저장하기 전에 validation을 수행하세요. 예: assert len(snac_tokens) > 0, assert total_tokens < 2048.
💡 주기적으로 데이터를 셔플하고 train/val/test로 분할하세요. scikit-learn의 train_test_split을 사용하면 편합니다.