LabHub
시작하기
배우기 러닝패스 코스

음성 AI 에이전트 — 듣고, 찾고, 말하는 파이프라인

48 kHz 원본을 16 kHz 프레임으로 — 접힘을 재고 막는다

LabHub 에서 이어서 보기

목표

48 kHz 스테레오 녹음을 음성 모델이 받는 모양(16 kHz · 모노 · 16비트 · 20 ms 프레임)으로 바꾸면서, 거르지 않은 리샘플링이 만드는 접힘 소리와 dBFS·SNR 을 숫자로 확인한다.

왜 중요한가

음성 파이프라인에서 가장 흔한 사고는 모델이 아니라 소리의 모양에서 난다. 샘플레이트·채널·바이트 순서가 어긋나도 오류는 나지 않고 인식률만 조용히 무너진다. 원본(/opt/lab/fixtures/voice/audio/caller_48k.wav)에는 12 kHz 파일럿 톤을 일부러 섞어 두었다 — 16 kHz 로 내릴 때 거르지 않으면 이 톤이 4 kHz 로 접혀 들리는 소리가 된다. 채점기는 여러분이 저장한 파일을 다시 읽어 4 kHz 성분의 세기, 프레임별 dBFS, 실제 SNR, 바이트 배열을 직접 잰다.

단계

  1. /opt/lab/fixtures/voice/audio/caller_48k.wav 의 헤더를 읽어 /root/voice/audio/info.json 에 sample_rate·channels·sample_width_bytes·frames·duration_s 를 적으세요.
  2. 두 채널의 평균으로 모노를 만들어 48 kHz 그대로 /root/voice/audio/mono48k.wav 에 저장하세요.
  3. 거르지 않고 세 칸마다 하나씩 뽑아(x[::3]) /root/voice/audio/naive16k.wav(16000 Hz)에 저장하세요.
  4. 8 kHz 아래만 통과시키는 저역 통과 필터를 건 뒤 세 칸마다 뽑아 /root/voice/audio/mono16k.wav(16000 Hz)에 저장하세요.
  5. mono16k.wav 를 20 ms(320 샘플) 프레임으로 나눠 각 프레임의 RMS dBFS 를 /root/voice/audio/levels.csv(index,start_s,dbfs)에 적으세요.
  6. -40 dBFS 미만이 200 ms(10프레임) 이상 이어진 구간을 /root/voice/audio/silence.json 에 [{"start_s":…,"end_s":…}] 로 적으세요.
  7. 시드 7 의 표준 정규 소음(np.random.default_rng(7).standard_normal(n))을 SNR 10 dB 가 되게 섞어 /root/voice/audio/noisy10.wav 에 저장하고, 저장한 파일로 다시 잰 값을 /root/voice/audio/snr.json 에 적으세요.
  8. mono16k.wav 를 s16le 20 ms 프레임(640 바이트)으로 이어 쓴 /root/voice/audio/stream.pcm 과 형식을 적은 /root/voice/audio/stream.json 을 만드세요. 마지막 프레임의 모자란 자리는 0 으로 채웁니다.

참고

헤더부터 읽는다

/opt/lab/fixtures/voice/audio/caller_48k.wav 를 wave 로 열어 /root/voice/audio/info.json 에 sample_rate·channels·sample_width_bytes·frames·duration_s(초, 소수 셋째 자리) 를 적으세요.

wave.open(...).getframerate()·getnchannels()·getsampwidth()·getnframes() 가 네 값을 줍니다. frames 는 '채널마다 하나씩 묶은 샘플' 의 수라서 길이는 frames ÷ 샘플레이트입니다.

두 채널을 평균해 모노로

원본의 두 채널을 평균해 모노로 만들고 48000 Hz 그대로 /root/voice/audio/mono48k.wav(16비트)에 저장하세요.

(프레임, 채널) 배열의 axis=1 평균입니다. 더하기만 하면 큰 소리에서 1.0 을 넘어 잘립니다. 쓸 때는 32768 을 곱해 반올림하고 -32768~32767 로 자릅니다.

거르지 않고 뽑아 본다

mono48k.wav 에서 세 칸마다 하나씩(x[::3]) 뽑아 /root/voice/audio/naive16k.wav(16000 Hz 모노)에 저장하세요. 일부러 틀린 방법입니다.

48000 ÷ 3 = 16000 이니 뽑기만 하면 샘플레이트는 맞습니다. 그런데 원본의 12 kHz 톤은 어디로 갔을까요? 채점 메시지가 4 kHz 성분의 세기를 알려 줍니다.

걸러서 내린다

mono48k.wav 에 새 나이퀴스트(8 kHz)보다 낮은 차단 주파수의 저역 통과 필터를 건 뒤 세 칸마다 뽑아 /root/voice/audio/mono16k.wav(16000 Hz 모노)에 저장하세요. 4 kHz 접힘 성분이 그냥 뽑았을 때보다 30 dB 이상 작아야 합니다.

창을 씌운 sinc 가 가장 간단한 저역 통과 필터입니다: h = 2·fc·sinc(2·fc·n)·hamming, fc 는 차단 주파수 ÷ 48000. h 의 합으로 나눠 이득을 1 로 맞추고 np.convolve(x, h, mode='same') 로 겁니다. 탭 수를 늘릴수록 차단이 가파릅니다.

20 ms 프레임의 세기(dBFS)

mono16k.wav 를 320 샘플(20 ms) 프레임으로 나눠(끝의 모자란 조각은 버림) 프레임마다 index,start_s,dbfs 를 /root/voice/audio/levels.csv 에 적으세요. dBFS 는 20·log10(RMS), 완전 무음은 -120 입니다.

RMS 는 제곱의 평균의 제곱근입니다. 샘플을 32768 로 나눈 실수에서 재야 0 dBFS 가 최대가 됩니다. 10·log10 을 쓰면 값이 반으로 줄어듭니다(전력이 아니라 진폭의 RMS 이므로 20).

무음 구간 찾기

levels.csv 에서 -40 dBFS 미만이 10프레임(200 ms) 이상 이어진 구간을 /root/voice/audio/silence.json 에 [{"start_s": …, "end_s": …}] 로 적으세요. 끝은 '처음으로 문턱을 넘은 프레임의 시작', 파일 끝까지 이어지면 마지막 프레임의 끝입니다.

문턱 아래로 들어간 프레임 번호를 기억해 두고, 문턱 위로 나오는 순간 길이를 봅니다. 목록 끝에 큰 값 하나를 덧붙이면 마지막 구간도 같은 코드로 닫힙니다.

SNR 10 dB 로 소음 섞기

mono16k.wav 와 같은 길이의 np.random.default_rng(7).standard_normal(n) 소음을 SNR 10 dB(전력비)가 되게 배율을 맞춰 더해 /root/voice/audio/noisy10.wav 에 저장하고, 저장한 파일을 다시 읽어 잰 SNR 을 /root/voice/audio/snr.json 의 measured_db 에 적으세요(target_db 는 10).

SNR = 10·log10(P신호 / P소음), P 는 제곱 평균입니다. 소음에 곱할 배율은 sqrt(P신호 / 10^(10/10) / P소음). 저장하면 16비트로 반올림되므로 다시 읽어 noisy − clean 으로 재야 정확합니다.

웹소켓이 나를 640 바이트 프레임으로

mono16k.wav 의 샘플을 s16le(16비트, 작은 바이트 먼저)로 20 ms(320 샘플 = 640 바이트)씩 이어 쓴 /root/voice/audio/stream.pcm 을 만들고(마지막 프레임은 0 으로 채움), /root/voice/audio/stream.json 에 format("s16le")·sample_rate·channels·frame_ms·frame_bytes·frames 를 적으세요.

WAV 머리글 없이 샘플만 씁니다. np.int16 배열의 .tobytes() 는 기계의 바이트 순서를 따르므로 dtype 을 '