들어가며
지난 글에서는 우리가 들을 수 있는 소리의 파형 속에서 인공지능이 의미를 찾아낼 수 있도록, 인간의 청각과 음악적 인지를 수학적으로 모델링한 MFCC와 Chroma 특징을 추출하는 방법을 알아보았습니다. 즉, 지금까지의 과정이 신호를 해부하고 분석(Analysis)하는 시간이었다면, 이제는 그 지식을 바탕으로 소리를 창작하고 가공(Creation & Manipulation)하는 무대로 넘어갈 차례입니다.
노래방 마이크를 잡았을 때 목소리를 풍성하게 만들어주는 '에코(Echo)', 일렉트릭 기타의 몽환적인 소리를 만드는 '리버브(Reverb)', 그리고 예능 프로그램에서 목소리를 변조하거나 가수의 음정을 보정해 주는 '피치 시프팅(Pitch Shifting)'. 우리가 일상과 음악 속에서 흔히 접하는 이 마법 같은 효과들은 사실 마법이 아니라 정교한 수학과 디지털 신호 처리(DSP)의 결과물입니다.
이번 글에서는 분석의 영역을 넘어 창작의 영역으로 들어갑니다. 소리에 공간감을 부여하는 딜레이와 리버브의 수학적 원리를 이해하고, 시간과 주파수를 주무르는 피치 시프팅의 비밀을 파헤치며 Python으로 나만의 오디오 이펙터를 직접 구현해 보겠습니다.
1. 딜레이(Delay): 모든 공간계 이펙터의 출발점
딜레이는 가장 단순하면서도 근본적인 시간 기반 이펙터이며, 우리가 흔히 말하는 '에코'가 바로 그 대표적인 형태입니다. 뒤에서 다룰 리버브조차 결국 딜레이를 여러 개 정교하게 쌓아 만든 것이므로, 이 개념을 정확히 이해하는 것이 출발점입니다.
원리는 한 문장으로 요약됩니다. 원래 신호에 시간을 늦춘 복사본을 더하는 것입니다. 디지털 신호에서 '시간을 늦춘다'는 것은 곧 '샘플을 뒤로 민다'는 뜻입니다. 지연 시간을 샘플 수로 환산하는 식은 다음과 같습니다.
샘플링 주파수 44.1kHz 기준으로 환산하면, 50ms는 2,205 샘플, 100ms는 4,410 샘플, 300ms는 13,230 샘플, 500ms는 22,050 샘플에 해당합니다.
1.1. 단일 에코: 피드포워드 콤 필터
가장 직관적이고 구현하기 쉬운 이펙터입니다. 산 정상에서 야호! 하고 외치면 잠시 후 야호! 하고 돌아오는 메아리와 같습니다. 원본 신호에 일정 시간 지연(Delay Time)된 신호를, 볼륨을 줄여서 더해주는 원리입니다.
가장 기본적인 형태는 지연된 복사본을 딱 한 번만 더하는 것입니다. 수식으로는 다음과 같이 표현합니다.
여기서 $D$는 지연 샘플 수, $g$는 에코의 크기를 정하는 이득(Gain)입니다. 출력이 과거의 '입력'에만 의존하므로, 이는 Part 3에서 배운 FIR 필터(피드포워드 구조)에 정확히 해당합니다.
def delay_feedforward(x, fs, delay_ms=300, g=0.6):
"""피드포워드 딜레이: 원음 + 지연된 복사본 한 개"""
D = int(fs * delay_ms / 1000) # 지연 샘플 수
y = np.zeros(len(x) + D)
y[:len(x)] += x # 원음 (dry)
y[D:D + len(x)] += g * x # 지연된 복사본 (wet)
return y
이 시스템에 임펄스(Impulse)를 넣어 응답을 확인하면, $D = 5, g = 0.6$일 때 $n=0$ 위치에 원음 1.0이, $n=5$ 위치에 에코 0.6이 정확히 찍힙니다. 즉 단 하나의 또렷한 메아리가 생기는 구조입니다.
이 구조를 '콤 필터(빗 필터)'라고 부르는 이유는 주파수 영역에서 드러납니다. 주파수 응답의 크기 $|H(f)|$는 $1 - g$와 $1 + g$ 사이를 주기적으로 진동합니다($g = 0.6$이면 0.4와 1.6 사이). 그 결과 일정 간격마다 깊은 골(Notch)이 빗살처럼 규칙적으로 나타나는데, 이 골의 간격이 정확히 $f_s / D$입니다. 위의 예처럼 $D = 441$(10ms)이면 100Hz마다 빗살이 생깁니다.
# 콤 필터의 빗살(comb) 주파수 응답 확인
fs = 44100
D = 441 # 10ms -> 빗살 간격 fs/D = 100Hz
imp = np.zeros(8192); imp[0] = 1.0
h = delay_feedforward(imp, fs, delay_ms=1000 * D / fs, g=0.6)[:8192]
f = np.fft.rfftfreq(len(h), 1 / fs)
H = np.abs(np.fft.rfft(h))
plt.figure(figsize=(10, 4))
plt.plot(f, H)
plt.title("피드포워드 콤 필터의 주파수 응답 (빗살 패턴)")
plt.xlabel("주파수 (Hz)"); plt.ylabel("|H(f)|")
plt.xlim(0, 2000); plt.grid(True, alpha=0.3)
plt.tight_layout();
plt.savefig('feedforward_echo.png', dpi=150, bbox_inches='tight')
plt.show()

1.2. 반복되는 메아리: 피드백 콤 필터
실제 메아리는 한 번 울리고 끝나는 것이 아니라, 점점 작아지며 여러 번 반복됩니다. 노래방의 그 풍성한 울림도 단 한 번의 에코로는 흉내 낼 수 없습니다. 이렇게 메아리가 반복되며 줄어들게 하려면, 출력된 신호를 다시 입력으로 되먹이는 피드백(Feedback) 구조를 사용합니다. 이것은 우리가 18편에서 배운 IIR 필터의 구조와 정확히 같습니다.
$$y[n] = x[n] + g \cdot y[n - D]$$
앞 식과 단 한 글자, $x$가 $y$로 바뀌었을 뿐이지만 의미는 완전히 달라집니다. 임펄스 응답을 살펴보면 그 차이가 분명합니다. 지연 100ms, $g = 0.6$으로 임펄스를 넣으면 0, 100, 200, 300ms...마다 에코가 나타나고, 그 크기는 1.0, 0.6, 0.36, 0.216, 0.1296...으로 정확히 $g^k$ 형태로 지수 감쇠합니다. 단일 에코가 만들던 단 하나의 봉우리와 달리, 무한히 줄지어 감쇠하는 이 에코 열(列)이 바로 그 풍성한 울림의 정체입니다.
def delay_feedback(x, fs, delay_ms=300, g=0.6):
"""피드백 딜레이: 출력을 되먹여 점점 작아지는 무한 에코 생성"""
D = int(fs * delay_ms / 1000)
y = np.copy(x).astype(float)
for n in range(D, len(y)):
y[n] += g * y[n - D]
return y
# 임펄스 응답으로 감쇠하는 에코 확인 (지연 100ms, 버퍼 0.7초)
fs = 44100
imp = np.zeros(int(fs * 0.7)); imp[0] = 1.0
echo = delay_feedback(imp, fs=fs, delay_ms=100, g=0.6)
t_ms = np.arange(len(echo)) / fs * 1000 # 시간축(ms)
plt.figure(figsize=(10, 4))
markerline, stemline, baseline = plt.stem(t_ms, echo)
plt.setp(markerline, markersize=4)
plt.setp(baseline, visible=False) # 0 기준선 숨김
plt.title("피드백 딜레이의 임펄스 응답 (지수적으로 감쇠하는 에코)")
plt.xlabel("시간 (ms)"); plt.ylabel("진폭")
plt.grid(True, alpha=0.3); plt.tight_layout();
plt.savefig('feedback_echo.png', dpi=150, bbox_inches='tight')
plt.show()

여기서 안정성(Stability)을 반드시 짚어야 합니다. 에코가 매번 $g$배씩 작아지려면 $|g| < 1$이어야 합니다. 만약 $g \ge 1$이면 메아리가 줄어들기는커녕 점점 커져 무한히 발산하는데, 이는 마이크를 스피커에 가까이 댈 때 발생하는 하울링(Howling)과 같은 현상입니다. 이 조건은 Part 3의 Z-변환에서 배운 "극점이 단위원 안에 있어야 한다"는 안정성 조건과 정확히 일치합니다. 이론과 청각이 만나는 지점입니다.
한 가지 구현상의 특징도 짚어둡니다. 피드백 구조는 각 출력이 이전 출력에 의존하기 때문에, NumPy의 벡터화로 한 번에 계산할 수 없고 위와 같이 for 루프로 순차 처리해야 합니다. 이 재귀적 특성과 그 가속화는 Part 6에서 다시 다룰 주제입니다.
2. 리버브(Reverb): 수많은 메아리로 공간을 빚다
딜레이가 또렷이 구분되는 메아리를 만든다면, 리버브는 공간 그 자체의 울림을 만듭니다. 둘은 비슷해 보이지만 청각적 인상은 전혀 다릅니다.
2.1. 잔향의 정체와 RT60
실제 공간에서 소리는 귀에 직접 도달하는 것 외에도, 벽·바닥·천장에 부딪혀 수천 갈래의 경로로 반사되어 들어옵니다. 각 반사음은 저마다 다른 지연과 감쇠를 가지며, 우리 뇌는 이 촘촘하고 불규칙한 메아리들을 하나의 연속된 '꼬리'로 융합해 인식합니다. 이것이 잔향(Reverberation)입니다.
앞 절에서 만든 피드백 딜레이는 일정한 간격으로 반복되는 또렷한 에코를 만듭니다. 하지만 이렇게 규칙적이고 듬성한 메아리는 금속을 두드린 듯한 기계적인 소리로 들릴 뿐, 자연스러운 공간감을 주지 못합니다. 진짜 잔향은 불규칙하고 촘촘하게 밀집된 수백 개의 반사음을 필요로 합니다.
공간의 울림을 정량화하는 대표적인 지표가 RT60입니다. 이는 음원이 멈춘 뒤 소리의 크기(음압)가 60dB, 즉 1/1000로 줄어드는 데 걸리는 시간을 말합니다. 작은 방은 대략 0.3초, 콘서트홀은 약 2초, 거대한 성당은 5초 이상에 이르기도 합니다.
콤 필터의 이득 $g$와 RT60의 관계는 간단한 수식으로 유도됩니다. 루프 지연 시간을 $\tau$라 하면 $\tau$초마다 진폭이 $g$배가 되므로, 시간 $t$가 지난 뒤 진폭은 $g^{t/\tau}$입니다. 이것이 $10^{-3}(=-60\text{dB})$이 되는 시점을 RT60으로 두고 풀면 다음을 얻습니다.
지연 29.7ms 콤 필터에 이 식으로 $g$를 설정하고 실제 임펄스 응답의 감쇠를 측정해 보면, RT60이 약 1.01초로 목표값 1.0초와 거의 일치함을 확인할 수 있습니다.
2.2. Schroeder 리버브레이터: 콤 + 올패스
자연스러운 잔향을 만드는 고전적인 해법은 1962년 Manfred Schroeder가 제안한 구조입니다. 핵심 아이디어는 두 종류의 필터를 조합하는 것입니다. 여러 개의 콤 필터를 병렬로 연결해 에코의 밀도를 높이고, 그 뒤에 올패스 필터를 직렬로 연결해 음색을 해치지 않으면서 잔향을 더 촘촘하게 다듬습니다.
먼저 병렬 콤 필터입니다. 서로 조금씩 다른, 가급적 서로소에 가까운 지연 시간(약 30~44ms)을 가진 콤 필터 4개를 만들어 더합니다. 만약 모든 콤의 지연이 같다면 에코가 같은 시점에 겹쳐 주기성이 강조되어 금속성 울림이 생깁니다. 지연을 서로 어긋나게 두어야 에코들이 시간축에 고르게 퍼집니다. RT60을 1.0초로 목표할 때 각 콤의 이득은 다음과 같이 계산됩니다.
| 지연 시간 | τ | 이득 g |
|---|---|---|
| 29.7 ms | 0.0297 s | 0.8145 |
| 37.1 ms | 0.0371 s | 0.7739 |
| 41.1 ms | 0.0411 s | 0.7528 |
| 43.7 ms | 0.0437 s | 0.7394 |
다음은 올패스 필터입니다. 그 정의는 다음과 같습니다.
올패스 필터의 핵심 성질은 이름 그대로 '전 대역 통과'입니다. 모든 주파수에서 크기 응답이 정확히 $|H(f)| = 1$로 평탄합니다(실제로 측정한 편차는 $10^{-5}$ 수준에 불과합니다). 즉 음색은 전혀 바꾸지 않으면서 위상만 흩뜨려 잔향의 밀도와 확산감(Diffusion)만 더해줍니다. 음색을 건드리지 않고 공간감만 두껍게 만들고 싶을 때 올패스 필터가 이상적인 이유가 바로 여기에 있습니다.
2.3. Python 구현
이제 두 필터를 조합해 완전한 Schroeder 리버브를 구현합니다.
def comb_feedback(x, D, g):
"""피드백 콤 필터"""
y = np.copy(x).astype(float)
for n in range(D, len(y)):
y[n] += g * y[n - D]
return y
def allpass(x, D, g):
"""올패스 필터 (크기는 평탄, 위상만 변화)"""
y = np.zeros_like(x, dtype=float)
for n in range(len(x)):
xd = x[n - D] if n - D >= 0 else 0.0
yd = y[n - D] if n - D >= 0 else 0.0
y[n] = -g * x[n] + xd + g * yd
return y
def schroeder_reverb(x, fs, rt60=1.0, wet=0.3):
"""병렬 콤 4개 + 직렬 올패스 2개로 구성한 Schroeder 리버브"""
comb_delays_ms = [29.7, 37.1, 41.1, 43.7]
allpass_delays_ms = [5.0, 1.7]
# 1) 병렬 콤 필터 합산
wet_sig = np.zeros(len(x))
for ms in comb_delays_ms:
tau = ms / 1000.0
D = int(fs * tau)
g = 10 ** (-3 * tau / rt60)
wet_sig += comb_feedback(x, D, g)
wet_sig /= len(comb_delays_ms)
# 2) 직렬 올패스 필터 통과
for ms in allpass_delays_ms:
D = int(fs * ms / 1000.0)
wet_sig = allpass(wet_sig, D, 0.7)
# 3) dry/wet 믹스
return (1 - wet) * x + wet * wet_sig
이 리버브에 임펄스를 넣어 응답을 분석하면, 첫 50ms 이내에만 232개의 반사음이 촘촘하게 들어찹니다. 듬성했던 단일 에코와 달리, 이렇게 빠르게 밀집되는 반사 패턴이 바로 우리 귀가 '또렷한 메아리'가 아닌 '자연스러운 공간'으로 인식하는 핵심입니다.
한 가지 정직하게 짚어둘 점이 있습니다. 각 콤 필터를 RT60=1.0초 기준으로 설계했지만, 병렬 합산과 올패스 단을 거친 최종 잔향의 실측 감쇠는 약 0.7초로 측정됩니다. 여러 단을 거치며 에너지가 재분배되기 때문인데, 이는 설계 파라미터가 곧 최종 결과와 1:1로 같지 않음을 보여줍니다. 실제 응용에서는 청감으로 rt60과 wet 값을 미세 조정해 원하는 공간감을 맞추게 됩니다.
3. 피치 시프팅(Pitch Shifting): 음정을 바꾸는 원리
마지막 이펙터는 목소리의 음정을 올리고 내리는 피치 시프팅입니다. 셋 중 가장 직관에 반하는, 그래서 가장 흥미로운 주제입니다.
3.1. 리샘플링의 딜레마 — 음정과 길이의 결합
음정을 높이는 가장 단순한 발상은 "빨리 재생하면 된다"는 것입니다. 실제로 샘플을 더 빠르게 읽어 들이면(리샘플링) 음정이 올라갑니다. 그러나 여기에는 함정이 있습니다. 빨리 재생하면 음정이 올라가는 동시에 재생 시간도 짧아지고, 느리게 재생하면 음정이 내려가는 동시에 길이가 늘어납니다. LP 레코드판을 빠른 속도로 돌릴 때 나는 다람쥐 같은 목소리(Chipmunk Effect)가 바로 이것입니다. 음정과 시간이 하나로 묶여 있는 것입니다.
리샘플링 비율을 $r$이라 하면 모든 주파수가 $r$배로 바뀌지만 길이는 $1/r$배가 됩니다. 따라서 음정만 $r$배로 올리고 길이는 그대로 두려면, 먼저 길이를 $r$배로 늘려놓은 뒤 리샘플링으로 다시 원래 길이로 되돌려야 합니다. 정리하면 다음과 같습니다.
피치 시프팅 = 타임 스트레칭(길이 변경) + 리샘플링(길이 복원)
3.2. 위상 보코더(Phase Vocoder): 시간과 주파수의 분리
그렇다면 음정은 그대로 둔 채 길이만 늘이는 '타임 스트레칭'은 어떻게 할까요. 바로 이 지점에서 Part 2의 STFT(단시간 푸리에 변환)가 다시 등장합니다.
1) STFT 수행: 신호를 시간-주파수 영역으로 변환합니다.
2) 시간 축 늘리기 (Time Stretching): 주파수 특성을 유지한 채 프레임 간의 겹침(Overlap)을 조절하여 오디오의 길이를 늘리거나 줄입니다. (이때 위상(Phase)이 틀어지지 않도록 교정하는 기술이 페이즈 보코더의 핵심입니다).
3) 리샘플링: 길어진 오디오를 다시 원래의 길이로 압축(리샘플링)합니다. 길이는 원본과 같아지고 음정만 변하게 됩니다.
음정 변화량을 반음(Semitone) 단위 $n$으로 표현할 때, 주파수 변화 비율은 다음과 같습니다.
12반음, 즉 한 옥타브가 정확히 2배가 되는 관계입니다.
3.3. Python 구현 (librosa)
위상 보코더를 처음부터 구현하는 것은 그 자체로 긴 주제이므로, 여기서는 지난 글에서 소개한 Librosa의 검증된 구현을 사용합니다. librosa.effects.pitch_shift는 내부적으로 앞서 설명한 '타임 스트레칭(위상 보코더) + 리샘플링' 과정을 그대로 수행합니다.
import librosa
def pitch_shift(x, fs, n_steps):
"""n_steps: 반음 단위. +12 = 한 옥타브 위, -12 = 한 옥타브 아래"""
return librosa.effects.pitch_shift(x, sr=fs, n_steps=n_steps)
220Hz(A3) 사인파에 이 함수를 적용해 검증하면 핵심 동작이 명확히 드러납니다. 신호의 길이는 변하지 않고 그대로 유지되면서, 우세 주파수만 다음과 같이 반음 단위로 정확히 이동합니다.
| 반음(n_steps) | 결과 주파수 | 이론값 220 × 2n/12 |
|---|---|---|
| +4 (장3도 위) | 277 Hz | 277.2 Hz |
| +7 (완전5도 위) | 330 Hz | 329.6 Hz |
| +12 (한 옥타브 위) | 440 Hz | 440.0 Hz |
| -5 | 165 Hz | 164.8 Hz |
다만 한계도 분명합니다. 음정을 크게 바꿀수록(특히 ±1옥타브를 넘어서면) 위상 보코더 모델이 타악기적 어택이나 큰 신축에서 무너지면서 금속성·인공적인 음질이 끼어듭니다. 음정과 시간을 완전히 자유롭게 분리하는 것은 원리적으로 불가능하며, 위상 보코더는 그 타협의 산물인 셈입니다.
마지막으로 지금까지 만든 세 이펙터를 실제 오디오 파일에 적용하는 전체 흐름을 정리합니다.
import librosa
import soundfile as sf
# 오디오 불러오기 (모노)
x, fs = librosa.load("voice.wav", sr=None, mono=True)
echo = delay_feedback(x, fs, delay_ms=250, g=0.5) # 메아리
room = schroeder_reverb(x, fs, rt60=1.2, wet=0.35) # 공간 울림
higher = pitch_shift(x, fs, n_steps=4) # 장3도 위로
# 결과 저장
sf.write("voice_echo.wav", echo, fs)
sf.write("voice_reverb.wav", room, fs)
sf.write("voice_higher.wav", higher, fs)
[Insight]
딜레이, 리버브, 피치 시프팅이라는 창작의 도구들을 분해해보면, 결국 우리가 Part 1~3에서 배운 디지털 필터, 컨볼루션, 그리고 푸리에 변환이라는 기초 재료들로 귀결됩니다.
- 딜레이는 단순한 덧셈을 결합한 FIR/IIR 필터의 응용입니다.
- 리버브는 콤과 올패스 같은 재귀 필터를 쌓아 공간의 임펄스 응답을 인공적으로 합성해내는 알고리즘 리버브의 정수입니다. (한편 실제로 측정한 공간의 임펄스 응답을 신호와 직접 컨볼루션하는 '컨볼루션 리버브'라는 또 다른 갈래도 있습니다.)
- 피치 시프팅은 시간 영역에서는 불가능한 조작을 STFT를 통해 주파수 영역으로 넘어가서 해결한 아름다운 수학적 트릭입니다.
오늘날 전 세계 수백만 명의 아티스트들이 사용하는 프로 오디오 소프트웨어(DAW)와 VST 플러그인들은 모두 이 수학적 원리 위에서 동작합니다. 최근에는 공간의 임펄스 응답(RIR: Room Impulse Response)을 직접 측정해 적용하는 대신 인공지능이 공간의 잔향을 학습해 생성하는 AI 리버브 플러그인이나, 위상 보코더 대신 딥러닝(GAN, Diffusion)을 통해 가수 본인의 목소리 특성을 완벽히 유지하며 음정을 바꾸는 뉴럴 피치 시프터로 기술의 패러다임이 한 단계 더 진화하고 있습니다.
하지만 그 어떤 최신 AI 오디오 기술도, 오늘 살펴본 기초적인 DSP 개념을 완벽하게 이해하는 것에서부터 출발합니다.
다음 글 예고
지금까지 우리는 이미 녹음되어 저장된 오디오 파일(WAV, MP3)을 불러와 필터를 씌우고, 특징을 추출하고, 이펙터를 입히는 오프라인(Offline) 처리에 집중했습니다. 하지만 우리가 만든 딜레이와 리버브가 진짜 노래방 마이크처럼 동작하려면, 소리가 입력되는 그 순간 곧바로 처리해 내보내야 합니다.
다음 시간에는 [23. 실시간 오디오 처리: PyAudio를 이용한 콜백 기반 분석기 제작] 편을 통해, 마이크로 들어오는 소리를 실시간으로 받아 그 자리에서 처리하고 출력하는 콜백(Callback) 기반 구조를 다룹니다. 버퍼 단위로 신호를 쪼개 처리하는 법, 끊김 없는 재생을 위한 지연(Latency) 관리, 그리고 오늘 만든 이펙터를 실시간 스트림 위에 얹는 법까지. 우리가 만든 효과들이 마침내 '라이브'로 살아 움직이는 모습을 함께 확인해 보겠습니다.
📌 이전 연재 글 보기
- [19] 적응 필터(Adaptive Filter): LMS 알고리즘 기반 에코 캔슬링 구현
- [20] 칼만 필터(Kalman): 센서 데이터 예측 및 필터링 기초
- [21] 오디오 특징 추출: Librosa를 활용한 MFCC, Chroma 추출 (AI 전처리)
