음성 AI 에이전트 — 듣고, 찾고, 말하는 파이프라인
48 kHz 원본을 16 kHz 프레임으로 — 접힘을 재고 막는다
목표
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, 바이트 배열을 직접 잰다.
단계
/opt/lab/fixtures/voice/audio/caller_48k.wav의 헤더를 읽어/root/voice/audio/info.json에sample_rate·channels·sample_width_bytes·frames·duration_s를 적으세요.- 두 채널의 평균으로 모노를 만들어 48 kHz 그대로
/root/voice/audio/mono48k.wav에 저장하세요. - 거르지 않고 세 칸마다 하나씩 뽑아(
x[::3])/root/voice/audio/naive16k.wav(16000 Hz)에 저장하세요. - 8 kHz 아래만 통과시키는 저역 통과 필터를 건 뒤 세 칸마다 뽑아
/root/voice/audio/mono16k.wav(16000 Hz)에 저장하세요. mono16k.wav를 20 ms(320 샘플) 프레임으로 나눠 각 프레임의 RMS dBFS 를/root/voice/audio/levels.csv(index,start_s,dbfs)에 적으세요.- -40 dBFS 미만이 200 ms(10프레임) 이상 이어진 구간을
/root/voice/audio/silence.json에[{"start_s":…,"end_s":…}]로 적으세요. - 시드 7 의 표준 정규 소음(
np.random.default_rng(7).standard_normal(n))을 SNR 10 dB 가 되게 섞어/root/voice/audio/noisy10.wav에 저장하고, 저장한 파일로 다시 잰 값을/root/voice/audio/snr.json에 적으세요. mono16k.wav를 s16le 20 ms 프레임(640 바이트)으로 이어 쓴/root/voice/audio/stream.pcm과 형식을 적은/root/voice/audio/stream.json을 만드세요. 마지막 프레임의 모자란 자리는 0 으로 채웁니다.
참고
- 읽기·쓰기: 표준 라이브러리
wave와numpy로 충분합니다. 이미지에 든 도우미from voicekit import read_wav, write_wav, to_int16, dbfs도 쓸 수 있습니다(모노 평균·16비트 변환을 해 줍니다). - 16비트 샘플은
np.frombuffer(raw, dtype="<i2")로 읽고 32768 로 나눕니다. 스테레오는(프레임, 채널)로 reshape 합니다. - 흔한 실수: 길이를
frames / sample_rate / channels로 계산하기(frames는 이미 채널 묶음의 수입니다), 두 채널을 더하기만 하기(넘칩니다), SNR 을 진폭비로 맞추기(10 dB 가 5 dB 가 됩니다). - 개념: Nyquist–Shannon sampling theorem · ITU-T G.711 · Python wave
헤더부터 읽는다
/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 을 '