워크스루 04 — 음성/음악에 FFT 돌리기

왜 하필 FFT냐면요

음성 인식, 음악 검색(Shazam), 노이즈 캔슬링, 보컬 분리 — 소리를 다루는 거의 모든 기술의 출발점이 푸리에 변환입니다. 시간에 따라 변하는 소리(시간 영역)를 “어떤 주파수들이 얼마나 섞여 있나”(주파수 영역)로 바꿔서 보는 도구입니다.

이 워크스루에서는 본인 목소리 한 줄이나 좋아하는 음악 몇 초를 가지고, 그 안에 어떤 주파수가 들어 있는지를 눈으로 봅니다. 익숙한 소리가 갑자기 그래프 위의 봉우리들로 보이는 경험 — 신호 처리 입문에서 이보다 강력한 순간이 별로 없습니다.

준비물

  • Octave 설치
  • signal 패키지 (sudo apt install octave-signal 또는 pkg install -forge signal)
  • 짧은 오디오 파일 하나. .wav 또는 .flac이 가장 좋고, 5~10초 정도가 적당합니다.

오디오 파일이 없다고요? 만들기 쉽습니다:

  • 리눅스에서 한 줄로 녹음:
arecord -d 5 -f cd voice.wav     # 5초 녹음
  • 또는 갖고 있는 노래 파일을 .wav로 변환:
ffmpeg -i song.mp3 -t 10 sample.wav    # 처음 10초만 .wav로

개념 — 한 단락 요약

소리는 시간에 따라 공기 압력이 출렁이는 파동입니다. 디지털에서는 그 출렁임을 1초에 수천~수만 번 측정해 숫자 배열로 저장합니다. CD 품질이면 1초에 44,100번(샘플링 주파수 44.1 kHz)입니다.

이 숫자 배열에 FFT (Fast Fourier Transform) 를 걸면, 같은 소리를 주파수 성분의 합으로 분해한 결과가 나옵니다. 440 Hz짜리 순수한 사인파라면 FFT 그래프의 440 Hz 위치에 봉우리 하나가 솟습니다. 노래라면 베이스(낮은 주파수), 보컬(중간), 심벌즈(높은 주파수)가 여러 봉우리로 보입니다.

시간에 따라 어떤 주파수가 등장하는지 보고 싶으면 — 음악은 시시각각 변하니까요 — 스펙트로그램을 그립니다. 짧은 시간 창마다 FFT를 걸어 색으로 표시한 그림인데, 음성·음악 분석의 핵심 도구입니다.

단계별로 가봅시다

1단계 — 오디오 읽고 모양 확인

% fft_audio.m
clear; close all;

pkg load signal

% 파일 읽기 — 본인의 경로로 바꾸세요
[y, fs] = audioread('sample.wav');

% 스테레오면 모노로 평균
if size(y, 2) == 2
  y = mean(y, 2);
end

fprintf('샘플 수: %d\n', length(y));
fprintf('샘플링 주파수: %d Hz\n', fs);
fprintf('재생 시간: %.2f 초\n', length(y)/fs);

% 시간 영역 파형 보기
t = (0:length(y)-1)' / fs;

figure;
plot(t, y);
xlabel('시간 [s]'); ylabel('진폭');
title('시간 영역 파형');
grid on;

여기까지 돌리면 시간에 따라 출렁이는 파형이 보입니다. 음성이라면 단어마다 진폭이 커졌다 작아졌다 하고, 음악이라면 비트가 보이기도 합니다.

2단계 — FFT로 주파수 스펙트럼

N = length(y);
Y = fft(y);

% 정규화된 크기 (실수 신호이므로 절반만 의미가 있음)
Y_mag = abs(Y) / N;
half  = 1:floor(N/2);
freqs = (0:N-1)' * (fs / N);

figure;
plot(freqs(half), Y_mag(half));
xlabel('주파수 [Hz]'); ylabel('크기');
title('전체 주파수 스펙트럼');
grid on;
xlim([0 5000]);    % 사람 목소리·음악은 보통 5 kHz 이내가 핵심

기대 결과:

  • 음성이라면: 100~300 Hz 부근에 큰 봉우리(기본 주파수 — 사람마다 다릅니다. 남성은 100~150 Hz, 여성은 180~250 Hz 정도)와 그 정수배 위치의 작은 봉우리들(배음).
  • 음악이라면: 베이스 영역(50~200 Hz)에 큰 에너지, 중간(500~2000 Hz)에 보컬·악기, 고음(2000+ Hz)에 심벌즈·치찰음.

로그 스케일로 보면 더 분명해집니다:

figure;
semilogy(freqs(half), Y_mag(half));
xlabel('주파수 [Hz]'); ylabel('크기 (로그)');
title('주파수 스펙트럼 (로그)');
grid on;
xlim([20 fs/2]);

3단계 — 스펙트로그램 (시간-주파수 그림)

음악이나 긴 음성은 FFT 한 번으로는 부족합니다. 시간이 지나면서 주파수가 어떻게 변하는지 봐야죠.

% 스펙트로그램
% 윈도우 크기 1024, 50% 겹침
window = 1024;
overlap = window / 2;

figure;
specgram(y, window, fs, hanning(window), overlap);
title('스펙트로그램');
colorbar;
ylim([0 5000]);

기대 결과:

  • 시간(가로축)에 따라 주파수(세로축) 에너지가 색으로 표시된 그림.
  • 음성이라면 단어마다 가로 줄무늬(배음 패턴)가 보입니다.
  • 음악이라면 멜로디 라인을 따라가는 봉우리, 비트마다 세로로 짧은 강한 색.

이 그림을 처음 본 분들은 종종 “내 목소리가 이렇게 생겼구나”라고 하십니다. 추상적이던 신호 처리가 갑자기 손에 잡히는 순간입니다.

4단계 — 필터 걸어서 들어보기

저역통과 필터로 고주파를 제거한 뒤 다시 들어보면 효과가 귀로 들립니다.

% 저역통과 필터 (500 Hz 컷오프)
fc = 500;
[b, a] = butter(4, fc / (fs/2));
y_filtered = filtfilt(b, a, y);

% 저장해서 들어보기
audiowrite('filtered.wav', y_filtered, fs);

% 필터링된 스펙트로그램
figure;
specgram(y_filtered, window, fs, hanning(window), overlap);
title('500 Hz 저역통과 필터 후');
colorbar;
ylim([0 5000]);

filtered.wav를 재생해 보세요. 음성이면 전화 통화처럼 답답하고 둔탁한 소리가, 음악이면 베이스만 남은 듯한 소리가 들립니다.

고역통과도 해봅시다:

fc = 1500;
[b, a] = butter(4, fc / (fs/2), 'high');
y_hp = filtfilt(b, a, y);
audiowrite('highpass.wav', y_hp, fs);

음성이면 “치찰음만 남고 모음이 사라진” 효과, 음악이면 베이스가 사라진 효과입니다.

5단계 — 잡음을 넣고 다시 지워보기

일부러 잡음을 섞은 다음, FFT로 잡음 주파수를 찾아봅시다.

% 60 Hz 윙윙거리는 잡음 (전기 잡음 모방) 추가
hum = 0.05 * sin(2*pi*60*t);
y_noisy = y + hum;

audiowrite('noisy.wav', y_noisy, fs);

% 새 스펙트로그램
figure;
specgram(y_noisy, window, fs, hanning(window), overlap);
title('60 Hz 잡음 추가');
colorbar;
ylim([0 500]);    % 잡음 영역 확대

그림 아래쪽(60 Hz 부근)에 평행한 가로줄이 진하게 보일 겁니다. 그게 잡음입니다. 이걸 대역소거 필터(notch filter)로 지우는 게 다음 도전입니다.

AI에게 물어보기 좋은 시점

시간 파형을 본 직후:

“이 시간 영역 파형에서 단어/음표의 경계를 어떻게 찾을 수 있나요? ‘에너지’라는 개념과 RMS를 이용한 segmentation 코드를 보여주세요.”

FFT 스펙트럼을 본 직후:

“주파수 그래프에서 봉우리들이 등간격으로 보이는 부분(배음 시리즈)이 있습니다. 이게 무엇이고, 왜 사람 목소리에 이런 패턴이 나타나는지 설명해 주세요. 순수 사인파라면 이 패턴이 어떻게 달라질까요?”

스펙트로그램을 본 직후:

“스펙트로그램의 윈도우 크기를 1024 → 256으로, 그리고 4096으로도 바꿔보고 싶어요. 각각 어떻게 보일지 예측해 주고, 시간 해상도 vs 주파수 해상도 트레이드오프를 설명해 주세요. 세 결과를 한 그림에 비교하는 Octave 코드도요.”

필터링 후 들어본 다음:

“저역통과 4차 필터와 8차 필터의 차이를 들어보고 싶어요. 같은 컷오프로 비교하는 코드를 보여주세요. 차수가 높을수록 뭐가 좋아지고 뭘 잃나요?”

잡음 분석 후:

“60 Hz 잡음만 지우는 대역소거 필터(notch filter)를 만들고 싶어요. 어떤 함수를 쓰면 좋을지, 필터링 후 스펙트로그램에서 가로줄이 사라지는지 확인하는 코드도 함께 부탁합니다.”

막힐 만한 곳

  • audioread가 안 됨: signal 패키지가 로드 안 됐거나 파일 형식이 지원되지 않는 경우입니다. pkg load signal 추가, .wav로 변환.
  • audiowrite 후 파일이 너무 시끄럽거나 깨짐: 진폭이 1을 넘었을 수 있습니다. y_filtered / max(abs(y_filtered))로 정규화하세요.
  • 스펙트로그램이 너무 흐릿: 윈도우 크기 문제입니다. 음성은 512~2048, 음악은 1024~4096이 적당합니다.
  • 주파수 축이 이상함: (0:N-1) * (fs/N)가 정확한 공식입니다. N이 홀수면 floor(N/2)로 자릅니다.
  • 처리가 너무 느림: y = y(1:fs*5); 처럼 처음 5초만 잘라 쓰세요.

확장 아이디어

  • 본인 목소리의 기본 주파수 찾기: “아~~~” 5초 녹음 후 FFT의 첫 봉우리 위치가 본인 음높이입니다. 노래할 때와 비교해 보세요.
  • 악기 분리 흉내: 베이스(20~200 Hz), 보컬(200~2000 Hz), 고음(2000+ Hz) 대역으로 분리해서 각각 저장.
  • 음성 인식 사전 단계: MFCC (Mel-Frequency Cepstral Coefficients) 추출. 거의 모든 음성 인식의 출발점입니다.
  • Shazam의 원리: 스펙트로그램에서 봉우리를 뽑아 “지문(fingerprint)”을 만드는 알고리즘 흉내. AI에게 “Shazam의 핵심 알고리즘을 단순화해서 Octave로 구현해줘”라고 해보세요.
  • 실시간 입력: 마이크 입력으로 스펙트로그램 갱신 (audiorecorder 사용).

내가 해본 기록

2026-07-25 — AI 에이전트와 함께 첫 실행 (headless 환경)

스크립트: wt04_fft_audio.m, 그래프: 같은 폴더의 wt04_*.png

목소리 샘플의 파형(위)과 주파수 스펙트럼(아래) — 249 Hz 기본 주파수와 배음

  • 오디오: 마이크가 없는 환경이라 시스템에 있던 실제 사람 목소리 샘플을 썼습니다 — /usr/share/sounds/alsa/Front_Center.wav (“Front, Center” 발화). 48 kHz, 1.43초.
  • 스펙트럼: 기본 주파수 봉우리가 249 Hz에서 잡혔습니다. 문서가 예고한 대로 배음들이 정수배 근처(492, 737, 983 Hz)에 줄지어 나타났고요. 그런데 정확한 정수배(499, 748, 997)보다 조금씩 낮게 나온 게 흥미로웠습니다. 말하는 동안 음높이가 흔들려서 클립 전체 FFT에서는 봉우리가 번지기 때문입니다 — 이런 걸 보려고 스펙트로그램이 있는 거였습니다.
  • 스펙트로그램: 단어 두 개(“Front”, “Center”)가 배음 가로 줄무늬 덩어리 두 개로 선명하게 보였습니다.
  • 필터링: headless라 직접 들을 수가 없어서, 듣는 대신 대역 에너지 비율로 검증했습니다. 저역통과(500 Hz)는 저역 99% 유지 + 1500 Hz 이상 사실상 0, 고역통과(1500 Hz)는 그 반대. 결과 wav 파일(wt04_lowpass.wav 등)은 저장해 뒀으니 스피커 있는 환경에서 들어볼 예정입니다.
  • 60 Hz 잡음 제거에서 실전 함정 발견: 문서 예고에 없던 것이었습니다. butter(4, [55 65]/(fs/2), 'stop')으로 notch를 만들면 fs=48 kHz에서 정지 대역이 너무 좁아 수치 불안정으로 출력이 전부 NaN이 됩니다. 에러도 안 나서 더 무섭습니다. 좁은 notch는 전용 함수 pei_tseng_notch를 써야 했고, 그 결과 60 Hz 성분이 28.8 dB 감쇠됐습니다. notch 전후 스펙트로그램에서 60 Hz 가로줄이 사라지는 것도 확인했습니다.
  • 확장도 해봤습니다 — 악기 분리 흉내(3대역 분리) (wt04_ext_bandsplit.m, 2026-07-26): 저음(<200 Hz)/중음(200~2000 Hz)/고음(>2000 Hz) 세 대역으로 쪼개 각각 wav로 저장했습니다. 에너지 분배가 14.7% / 59.2% / 5.2% — 목소리답게 보컬 대역(중음)에 에너지가 집중됩니다. 세 대역을 도로 더한 재합성과 원본의 상관계수는 0.9995. 그런데 대역 경계의 롤오프 겹침 때문에 에너지 합은 원본의 79%밖에 안 됩니다. 필터 뱅크는 에너지를 정확히 보존하지 않는다는 것도 이번에 배웠습니다.
  • 남은 확장: 진짜 제 목소리 녹음으로 기본 주파수 비교, Shazam 지문 흉내.