오늘은!
TTS 기술을 연계해서 좀 더 자연스러운 AI NPC 대화 시스템을 구현해보려고 한다.
그 전에 음성 스펙트럼 데이터를 갖고와서 애니메이션에 연동되는 예제부터 학습해보겠다.
1. 립싱크 NPC 만들기
Unity-Chan! Model | 3D 캐릭터 | Unity Asset Store
Elevate your workflow with the Unity-Chan! Model asset from unity-chan!. Find this & other 캐릭터 options on the Unity Asset Store.
assetstore.unity.com
우선 에셋 중에 발음을 표현할 수 있는 얼굴 애니메이션이 포함된 에셋을 다운로드 받아주었다.
/// <summary>
/// 직렬화 클래스 선언
/// 음성 데이터를 분석하여 특정 블렌디셰이프와 연결하는 역할
/// </summary>
[System.Serializable]
public class PhonemeMapping
{
public string phoneme; // 음소
public string blendShapeName; // 블랜더 이름
public float frequencyThreshold; // 주파수 대역 임계값
public float maxWeight = 100f; // 블랜드셰이프 가중치
}
우선 음성에 따라 얼굴 애니메이션 변환을 위해 음소정보, 블랜더셰이프, 주파수 임곗값, 가중치 등 기본적인 변수를 선언해주었다.
음소는 "아, 에, 이, 오, 우" 같은 같은 작은 소리 단위로
얼굴 애니메이션을 구분할 때 해당 음소값으로 애니메이션 값을 조절한다.
public float fftResolution = 512; // fft 성능
public float smoothingSpeed = 3f; // 블렌더셰이프 변환 부드럽게
public float volumeSensitivity = 50f; // 볼륨 감지 민감도
public float minVolume = 0.01f; // 최저 볼륨 감지 임계값
해당 변수들은 빠른 푸리에 변환에 사용할 값들이다.
❓ 빠른 푸리에 변환 (FFT, Fast Fourier transform) 이란?
소리나 신호를 분석하는 데 사용되는 알고리즘,
해당 알고리즘을 사용하면 시간에 따라 변하는 소리를 개별적인 주파수 성분으로 분해할 수 있다.
public SkinnedMeshRenderer faceMesh; // 표정 애니메이션 타겟
public PhonemeMapping[] phonemeConfigs; // 표정에 적용할 PhonemeMapping 클래스 배열
그 다음으로 애니메이션의 설정과 관련한 변수들을 선언해주었다.
이때 PhonemeMapping 클래스를 배열로 저장하여 음소별 블렌드셰이프 이름과 주파수 임곗값, 최대 가중치 같은 애니메이션 설정을 한 번에 관리할 수 있도록 해주었다.
private AudioSource audioSource;
private Dictionary<string, int> blendShapeIndexMap = new Dictionary<string, int>();
private float[] currentWeights;
private float[] spectrumData;
마지막으로 음성을 담을 오디오 소스와
음성 분석 결과에 따라 특정 블렌드셰이프를 활성하기 위한 딕셔너리,
애니메이션 강조를 위한 임계값 (값이 높을수록 강한 표정),
FFT 분석 결과를 저장하는 배열을 선언해주었다.
void Start()
{
audioSource = GetComponent<AudioSource>();
audioSource.loop = true;
audioSource.Play();
// fftResolution값을 정수로 변환하여 spcrtrumData 배열에 저장
// spcetrumData 배열을 활용하여 오디오의 특정 주파수 대역을 감지하고 이를 애니메이션과 연결
spectrumData = new float[(int)fftResolution];
// 블랜드셰이프 인덱스 매핑 초기화
for (int i = 0; i < faceMesh.sharedMesh.blendShapeCount; i++)
{
string name = faceMesh.sharedMesh.GetBlendShapeName(i);
blendShapeIndexMap[name] = i;
Debug.Log($"BlendShape Index {i}, {name}");
}
currentWeights = new float[faceMesh.sharedMesh.blendShapeCount];
}
Start() 함수에서는 음성 분석 기반 얼굴 애니메이션을 적용하기 위한 초기 세팅을 수행하였다.
- 우선 AudioSource를 가져와 반복 재생을 시작하고
- FFT 분석 결과를 저장할 배열을 생성하였다.
- 이후 얼굴 메쉬에 등록된 모든 블렌드셰이프의 이름과 인덱스를 매핑하여 음소에 대응하는 표정 값을 빠르게 찾을 수 있도록 구성하였고
- 각 블렌드셰이프의 현재 가중치를 저장할 배열도 함께 초기화하였다.
/// <summary>
/// NPC 애니메이션 실시간 조절
/// </summary>
private void LateUpdate()
{
// 오디오가 없거나 멈춰 있는 경우
if (!audioSource.isPlaying || audioSource.clip == null)
{
ResetBlendShapes(); // 블렌더셰이프 값 초기화
return;
}
AnalyzeAudio(); // 오디오 데이터 분석
UpdateBlendShapes(); // 분석 결과를 블렌드셰이프에 적용
}
private void ResetBlendShapes()
{
ResetBlendShapeWeights();
UpdateBlendShapes();
}
private void ResetBlendShapeWeights()
{
for (int i = 0; i < currentWeights.Length; i++)
currentWeights[i] = 0f;
}
private void UpdateBlendShapes()
{
for (int i = 0; i < currentWeights.Length; i++)
faceMesh.SetBlendShapeWeight(i, currentWeights[i]);
}
NPC의 얼굴 애니메이션을 실시간으로 조절해주기 위하여 LateUpdate 메서드를 활용해주었다.
- 오디오가 없거나 재생 중이지 않을 때에는 얼굴 애니메이션이 초기 상태로 유지될 수 있도록 하기 위해 ResetBlendShapes 함수를 호출해주었고
- 오디오가 있고 재생중일 경우에는 FFT분석을 통해 해당 분석 값을 토대로 얼굴 애니메이션이 적용될 수 있도록 해주었다.
🚨 표정 출력
SetBlendShapeWeight()는 SkinnedMeshRenderer에 등록된 특정 블렌드셰이프의 가중치를 설정하는 메서드로
계산된 음성 분석 결과를 실제 얼굴 메쉬의 표정 변화에 반영하는 역할을 한다.
인덱스로 대상 블렌드셰이프를 지정하고, 가중치 값을 통해 해당 표정이 얼마나 강하게 적용될지를 제어할 수 있다.
/// <summary>
/// AudioSource에 등록된 오디오 데이터를 분석하여 NPC의 입 모양을 실시간으로 조절하는 함수
/// 오디오 주파수 데이터를 FFT로 분석하고 매 프레임마다
/// 특정 주파수에 반응하는 블렌드셰이프 활성화
/// </summary>
private void AnalyzeAudio()
{
audioSource.GetSpectrumData(spectrumData, 0, FFTWindow.BlackmanHarris);
// 볼륨 계산 (선형 방식)
float rms = CalculateRMS(audioSource);
float linearVolume = Mathf.Clamp(rms * 100f, 0f, 100f);
if (linearVolume < minVolume)
{
ResetBlendShapeWeights();
return;
}
foreach (var config in phonemeConfigs)
{
// 밑에서 다루겠습니다~
}
}
이번 실습에서 킥이될 메서드이다.
FFT로 음성 데이터를 분석하고
매 프레임마다 특정 주파수에 반응하는 블렌드셰이프를 활성화하여
자연스럽게 말하는 애니메이션을 구현한다.
audioSource.GetSpectrumData(spectrumData, 0, FFTWindow.BlackmanHarris);
GetSpectrumData()는 Unity의 AudioSource에서 제공하는 메서드로
현재 재생 중인 오디오를 주파수 영역 데이터로 변환하여 배열에 저장하는 기능을 한다.
즉, 시간에 따라 변하는 소리 파형을 그대로 사용하는 것이 아니라
그 소리를 여러 개의 주파수 성분으로 나누어 어떤 대역의 소리가 강한지를 분석할 수 있도록 해준다.
이 메서드는 다음과 같은 매개변수를 필요로 한다.
- samples
FFT 분석 결과를 저장할 float[] 배열이다.
각 인덱스에는 특정 주파수 대역의 세기값이 저장된다. - channel
분석할 오디오 채널 번호이다.
일반적으로 0은 첫 번째 채널을 의미한다. - FFTWindow
FFT 계산 시 사용할 윈도우 함수이다.
주파수 분석 과정에서 신호를 더 안정적으로 처리하기 위해 사용된다.
❓ FFTWindow.BlackmanHarris란?
FFTWindow.BlackmanHarris는 FFT 분석 과정에서 사용하는 윈도우 함수(Window Function) 중 하나이다.
FFT는 일정 구간의 오디오 데이터를 잘라서 분석하는데
이때 구간의 시작과 끝이 갑자기 끊기면 실제와 다른 주파수 성분이 섞여 보이는 문제가 발생할 수 있다.
이를 스펙트럼 누출(Spectral Leakage) 이라고 한다.
BlackmanHarris 윈도우는 이러한 왜곡을 줄이기 위해 샘플 구간의 양 끝 값을 부드럽게 줄여주는 방식으로 동작한다.
즉, 분석 정밀도를 높이고 노이즈성 오차를 줄여 좀 더 안정적인 주파수 데이터를 얻을 수 있도록 도와준다.
float rms = CalculateRMS(audioSource);
float linearVolume = Mathf.Clamp(rms * 100f, 0f, 100f);
rms는 Root Mean Square의 약자로
오디오 신호의 평균적인 크기 또는 에너지를 계산하는 방식이다.
음성 데이터는 양수와 음수가 반복되는 파형이기 때문에
단순 평균을 내면 실제 크기를 제대로 반영하지 못할 수 있다.
따라서 RMS 값을 제곱한 뒤 평균을 구하고 다시 제곱근을 취하는 방식으로 계산하여 신호의 실제 세기를 정확하게 가져와 줄 것이다.
🥕 Mathf.Clamp 복습!
clamp는 어떠한 값이 지정한 범위를 벗어나지 않도록 제한하는 메서드로
Mathf.Clamp(제한하려는 값, 허용가능 최소값, 허용가능 최대값) 처럼 매개변수를 갖는다.
foreach (var config in phonemeConfigs)
{
if (!blendShapeIndexMap.TryGetValue(config.blendShapeName, out int targetIndex))
{
Debug.LogError($"BlendShape not found : {config.blendShapeName}");
continue;
}
float freqValue = GetFrequencyRangeValue(config.frequencyThreshold);
float targetWeight = Mathf.Clamp(
freqValue * linearVolume * volumeSensitivity,
0,
config.maxWeight
);
currentWeights[targetIndex] = Mathf.Lerp(
currentWeights[targetIndex],
targetWeight,
Time.deltaTime * smoothingSpeed
);
}
foreach 구문에서는 앞서 설정한 phonemeConfigs 배열을 순회하면서
각 음소에 대응하는 블렌드셰이프의 가중치를 계산한다.
즉, "아, 에, 이, 오, 우"와 같은 음소별 설정값을 하나씩 읽어와 해당 음소가 현재 얼마나 강하게 발음되고 있는지를 확인하고
그 결과를 얼굴 애니메이션 값으로 변환하는 과정이다.
- 먼저 TryGetValue()를 통해 현재 설정된 blendShapeName이 실제 얼굴 메쉬에 존재하는 블렌드셰이프인지 확인
- 존재하지 않을 경우 오류 로그를 출력하고 continue로 다음 음소 설정으로 넘어간다.
- 그 다음 GetFrequuencyRangeValue 메서드를 호출하여
- 해당 음소가 반응해야하는 주파수 대역의 값을 가져온다.
- 이 값은 현재 오디오에서 특정 음소에 가까운 소리가 얼마나 강하게 포함되어 있는지를 나타내는 기준값으로 사용될 것이다.
- 그리고 입 모양이 튀듯이 바꾸는 것을 방지하기 위해 Lerp 함수를 활용해 주었다.
🥕 Mathf.Lerp 복습!
Lerp는 두 값 사이를 일정 비율만큼 보간하는 메서드이다.
여기서는 현재 적용 중인 가중치(currentWeights[targetIndex]) 에서 목표 가중치(tartgetWeight)까지
한번에 급격히 바꾸는 것이 아니라 Time.deltaTime * smoothingSpeed 비율만큼 서서히 이동하도록 만든다.
/// <summary>
/// 재생 중인 오디오의 볼륨 측정
/// RMS 방식을 사용하여 음원의 평균 볼륨을 꼐산하고 이 값으로 NPC의 입모양 크기 조절
/// </summary>
private float CalculateRMS(AudioSource source)
{
float[] samples = new float[1024];
source.GetOutputData(samples, 0);
float sum = 0f;
foreach (float s in samples) // 제곱합 구하기
sum += s * s;
float rms = Mathf.Sqrt(sum / samples.Length); // 제곱근 취하기
return Mathf.Max(rms, 0.0001f); // RMS 값이 너무 작아 0에 수렴하는 경우 방지
}
CalculateRMS() 메서드는 AudioSource.GetOutputData()를 통해 현재 재생 중인 오디오 샘플을 가져온 뒤
RMS(Root Mean Square) 방식으로 평균 음량을 계산하는 함수이다.
각 샘플 값을 제곱해 평균을 구하고 Mathf.Sqrt()로 제곱근을 취함으로써 음성의 실제 에너지 크기를 안정적으로 측정하고
Mathf.Max()를 사용해 최소값을 보정하여 후속 입모양 가중치 계산이 지나치게 0에 가까워지지 않도록 구성하였다.
/// <summary>
/// NPC 입 모양 애니메이션 조절
/// </summary>
private float GetFrequencyRangeValue(float targetFreq)
{
int bin = Mathf.FloorToInt(targetFreq * fftResolution / AudioSettings.outputSampleRate);
bin = Mathf.Clamp(bin, 0, spectrumData.Length - 1);
return spectrumData[bin] * 1000f;
}
GetFrequencyRangeValue() 메서드는 특정 주파수 범위의 음성 신호 강도를 가져오는 역할을 한다.
FFT 데이터를 활용하여 주어진 주파수에 해당하는 스펙트럼값을 찾아 반환하며
이 값을 통해 NPC의 입 모양 애니메이션을 조절한다.
예를들어 'A'에 해당되는 특정 주파수 대역의 강도가 높아지면 해당하는 블렌드셰이프값을 더 높은 값으로 조절하여
NPC의 입이 더 크게 움직이도록 설정할 수 있다.
/// <summary>
/// 주파수 스펙트럼 시각화
/// </summary>
private void OnDrawGizmos()
{
if (spectrumData == null)
return;
for (int i = 0; i < spectrumData.Length; i++)
{
float height = spectrumData[i] * 1000;
Gizmos.color = Color.Lerp(Color.blue, Color.red, height / 10f);
Gizmos.DrawCube(new Vector3(i * 0.1f, height / 2, 0), new Vector3(0.05f, height, 0.05f));
}
}
마지막으로 주파수 스펙트럼을 시각적으로 확인하기 위해 기즈모 기능을 활용해주었다.
new Vector3(i * 0.1f, height / 2, 0)
- i * 01.f : x축 방향으로 간격을 주어 각 주파수 데이터를 옆으로 나열
- height / 2 : 막대의 중심 위치를 조정
new Vector3(0.05f, height, 0.05f) → 막대 너비, 높이, 깊이 설정

이제 인스펙터에서 음소별로 Phoneme Configs값을 설정해준 다음 실행하면 된다.

이러면 오디오 클립에 있는 음성에 맞게 입모양이 자연스럽게 움직이는 걸 확인할 수 있다 😆
'Unity > OpenAI' 카테고리의 다른 글
| [OpenAI API] 내 질문에 음성으로 대답하는 NPC 만들기 (0) | 2026.03.10 |
|---|---|
| [OpenAI API] 음성 인식 기반 AI NPC 구현하기 (0) | 2026.02.26 |
| [OpenAI API] Whisper API 사용해보기 (0) | 2026.02.24 |
| [OpenAI API] AI와 대화 시스템 만들기 (0) | 2026.02.12 |
| [OpenAI API] API 연결 & 동작 원리 확인하기 (0) | 2026.02.08 |