음성 AI 에이전트 — 듣고, 찾고, 말하는 파이프라인
소리를 숫자로 — 샘플레이트·프레임·dBFS, 그리고 접혀 들어오는 소리
한 줄 요약
마이크가 주는 것은 일정한 간격으로 잰 공기 압력의 숫자 열(PCM) 이다. 1초에 몇 번 쟀는지가 샘플레이트, 숫자 하나의 크기가 비트 수다. 음성 파이프라인의 모든 부품은 이 숫자 열을 20 ms 안팎의 프레임으로 나눠 주고받는다. 샘플레이트를 바꿀 때 거르지 않으면 원래 없던 소리가 생기고, 세기를 dBFS 로 재야 무음과 소음을 문턱 하나로 가를 수 있다.
왜 이게 필요했나
음성 비서를 처음 만들면 모델부터 고른다. 그런데 현장에서 처음 터지는 사고는 모델이 아니라 소리의 모양에서 난다. 브라우저는 48 kHz 로 녹음하고, 전화망은 8 kHz 로 보내고, 이 코스의 인식 모델은 16 kHz 를 받고, 합성 모델은 22.05 kHz 로 낸다. 네 숫자가 한 통화 안에 다 나온다. 한 곳에서 샘플레이트를 잘못 적으면 소리가 두 배 빨라져 인식률이 0 이 되고(모듈 3 에서 실제로 잰다 — 8 kHz 를 16 kHz 라고 속이면 WER 100%), 바이트 순서를 틀리면 잡음만 남는다. 오류는 나지 않는다. 숫자는 여전히 숫자이기 때문이다.
어떻게 동작하나
샘플레이트와 나이퀴스트. 1초에 fs 번 재면 fs/2 Hz 까지의 성분만 표현할 수 있다(표본화 정리). 16 kHz 로 재면 8 kHz 까지다. 사람 말소리의 알아듣는 데 중요한 성분은 대부분 그 아래에 있어서, 음성 인식 모델은 16 kHz 를 표준으로 쓴다. 유선 전화(ITU-T G.711)는 8 kHz 로 재서 300~3,400 Hz 만 나른다 — 전화 목소리가 답답하게 들리는 이유다.
접힘(에일리어싱). 48 kHz 에서 16 kHz 로 내릴 때 세 칸마다 하나씩 뽑기만 하면, 8 kHz 위에 있던 성분이 사라지지 않고 아래로 접혀 들어온다. 12 kHz 성분은 16 − 12 = 4 kHz 로 나타난다. 원본에 없던 삐 소리다. 그래서 뽑기 전에 새 나이퀴스트(8 kHz)보다 낮은 곳에서 자르는 저역 통과 필터를 먼저 건다. 실습 원본에는 이것을 보라고 12 kHz 파일럿 톤(-26 dBFS)을 섞어 두었다. 그냥 뽑으면 4 kHz 에 -29 dBFS 가 생기고, 127탭 창 sinc 필터로 거른 뒤 뽑으면 -65 dBFS 로 떨어진다(이 이미지에서 실측). 반대로 8 kHz 를 16 kHz 로 올린다고 4 kHz 위의 소리가 생기지는 않는다 — 없던 정보는 만들어지지 않는다.
비트 깊이와 바이트 순서. 16비트 PCM 은 -32768~32767 의 정수다. 계산은 32768 로 나눈 [-1, 1) 실수로 하고, 파일과 네트워크에는 정수로 쓴다. 바이트는 작은 쪽이 먼저(little-endian)인 것이 보통이라 형식 이름이 s16le 다. 두 채널을 모노로 섞을 때는 평균을 낸다. 더하기만 하면 두 채널이 0.8 일 때 1.6 이 되어 잘린다(클리핑).
프레임과 세기. 소리를 20 ms(16 kHz 에서 320 샘플, 640 바이트)씩 끊어 다룬다. 사람 말의 성질은 수십 ms 동안 거의 그대로이고, 실시간 전송(WebRTC 의 Opus 등)도 20 ms 를 흔히 쓴다. 프레임의 세기는 RMS(제곱 평균의 제곱근)를 dBFS — 최대값(1.0)을 0 dB 로 둔 로그 눈금 — 로 적는다: 20·log10(RMS). -40 dBFS 는 최대의 1%, -60 dBFS 는 0.1% 다. 곱셈이 덧셈이 되므로 '말은 -20 근처, 방 소리는 -50 근처' 처럼 문턱 하나로 가를 수 있다.
SNR. 신호 대 잡음비는 전력의 비다: 10·log10(P신호 / P잡음). 진폭 비로 쓰면 20·log10 이 된다. 둘을 섞으면 10 dB 를 넣으려다 5 dB 나 20 dB 를 넣게 된다 — 실습의 반례가 바로 이것이다.
현장에서 만나는 모습
이 코스의 실시간 통신 코스와의 경계가 여기다. 그 코스가 브라우저 마이크를 웹소켓으로 서버까지 나르고, 이 코스는 "16 kHz · 모노 · s16le · 20 ms 프레임" 이 도착한다 는 약속에서 시작한다. 약속의 네 칸 중 하나라도 어긋나면 받는 쪽은 오류 없이 엉뚱한 소리를 듣는다. 그래서 형식을 메시지마다 싣지 않더라도 연결을 열 때 한 번은 주고받아 서로 확인하는 것이 좋다.
LabHub 의 언어 회화 연습 기능은 반대 방식을 쓴다 — 브라우저 MediaRecorder 로 한 문장을 통째로 녹음해 보낸다(backend/app/lang_talk.py, static/lang/talk.js). 한 번에 보내는 녹음의 상한을 '15초 × 16 kHz × 16비트에 여유를 둔 2 MB' 로 잡아 둔 것도 이 계산에서 나왔다. 통째로 보내면 구현은 단순하지만 사람이 말을 끝낸 뒤에야 인식이 시작된다. 프레임으로 나눠 보내는 이유가 그 대기 시간을 없애는 것이다(모듈 3·8).
프레임 크기에도 맞바꿈이 있다. 프레임이 작을수록 한 조각을 모으는 데 드는 시간(20 ms 프레임이면 20 ms)이 줄지만, 메시지 수가 늘어 머리글·시스템 호출의 몫이 커진다. 반대로 100 ms 로 키우면 메시지는 다섯 배 줄지만 모든 단계가 최소 100 ms 씩 늦게 소리를 받는다. 이 코스의 스트리밍 ASR 은 100 ms 조각으로 받는다 — 웹소켓 프레임 다섯 개를 모아 한 번에 넣는 셈이다.
다음 실습에서 할 것
48 kHz 스테레오 원본의 헤더를 읽고, 모노로 섞고, 거르지 않고 16 kHz 로 내려 4 kHz 접힘을 재고, 저역 통과 필터로 제대로 내린다. 20 ms 프레임의 dBFS 를 내 무음 구간을 찾고, 시드를 고정한 소음을 SNR 10 dB 로 섞은 뒤, 웹소켓이 나를 640 바이트 프레임으로 잘라 저장한다.