실시간 통신 — WebSocket·gRPC 스트리밍·WebRTC
오디오 프레임을 WebSocket 과 WebRTC 로 보내고 지연을 잰다
목표
음성 AI 가 실제로 주고받는 16kHz 20ms 프레임을 만들어 WebSocket 과 WebRTC 로 보내고, 왕복 지연의 백분위·지터·재생 버퍼 크기를 직접 계산해 두 운반로를 숫자로 비교합니다.
왜 중요한가
음성 대화의 체감 품질은 평균 지연이 아니라 가장 늦은 1% 의 프레임과 재생 버퍼가 정합니다. WebSocket 은 서버 쪽 구현이 쉽고 방화벽을 잘 지나지만 TCP 라서 한 번 멈추면 뒤의 프레임이 전부 늦습니다. WebRTC 는 늦은 것을 버릴 수 있고 Opus 와 지터 버퍼를 갖췄지만 연결 준비가 무겁습니다. 음성 AI 코스에서 ASR·TTS 를 이 운반로 위에 올리기 전에, 어느 쪽이 어떤 조건에서 이기는지를 여기서 직접 잰 숫자로 확인합니다.
단계
- WAV 를 20ms 프레임으로 자른다 — /root/rt/voice/voice.py 에 frames(path, frame_ms=20) 를 만드세요. wave 모듈로 16비트 모노 WAV 를 읽어 frame_ms 길이의 bytes 조각 list 를 돌려줍니다. 조각 하나의 바이트 수는 표본율 × frame_ms / 1000 × 2 입니다. 마지막 조각이 모자라면 0 으로 채웁니다. 모노 16비트가 아니면 ValueError 입니다. 재료는 /opt/fixtures/rt/voice/speech16k.wav (16kHz, 3초) 입니다.
- 실제 시간에 맞춰 보낸다 — /root/rt/voice/voice.py 에 async send_paced(send, frames, frame_ms, prepare=None) 를 추가하세요. i 번째 프레임을 시작 시각 + i × frame_ms 에 맞춰 보냅니다. 그 시각이 되면 prepare 가 있으면 prepare(frame) 을 불러 그 결과를, 없으면 frame 을 await send(...) 로 넘깁니다. 각 프레임을 보낸 시각을 시작 시각 기준 초로 담은 list 를 돌려줍니다. 채점기는 한 번에 5ms 걸리는 prepare 를 줍니다.
- 평균이 아니라 백분위와 지터로 본다 — /root/rt/voice/voice.py 에 percentiles(samples, ps=(50, 95, 99)) 와 jitter(transit_ms) 를 추가하세요. percentiles 는 최근접 순위(nearest-rank) 방식으로 p 마다 정렬한 값의 ceil(p/100 × n) 번째를 골라 {p: 값} dict 를 돌려주고, samples 가 비면 ValueError 입니다. jitter 는 RFC 3550 의 도착 간격 지터 추정기로, 연속한 두 패킷의 전송 시간 차 D 의 절댓값으로 J = J + (|D| - J) / 16 을 되풀이한 최종 J 를 돌려줍니다(J 는 0 에서 시작하고, 값이 하나뿐이면 0).
- WebSocket 으로 보내고 왕복을 잰다 — /root/rt/voice/voice.py 에 async ws_measure(url, frames, frame_ms) 를 추가하세요. url 에 붙어 프레임마다 앞에 struct.pack("!IQ", 순번, time.monotonic_ns()) 12바이트를 붙여 send_paced 로 보내고, 메아리를 받을 때마다 지금 시각과 머리의 시각 차로 왕복 밀리초를 셉니다. 다 보낸 뒤 1초 안에 오지 않은 것은 잃은 것으로 칩니다. {"sent": 보낸 수, "received": 받은 수, "rtt_ms": 받은 순서의 왕복 list, "p50": 값, "p99": 값} 을 돌려줍니다. 그다음 /opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py ws 를 돌려 출력의 ws_p50_ms·ws_p99_ms·ws_stall_p99_ms 세 줄을 /root/rt/voice/report.txt 에 적으세요.
- WebRTC 데이터 채널로 같은 일을 한다 — /root/rt/voice/voice.py 에 async rtc_measure(frames, frame_ms) 를 추가하세요. 한 프로세스 안에 ICE 서버 목록을 비운 aiortc 피어 둘을 만들어 제안·응답을 직접 건네고, 제안 쪽이 ordered=False, maxRetransmits=0 인 데이터 채널을 엽니다. 응답 쪽은 받은 메시지를 그대로 돌려주고, 제안 쪽은 ws_measure 와 같은 머리·같은 방식으로 재서 같은 모양의 dict 를 돌려줍니다. 그다음 /opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py rtc 를 돌려 rtc_p50_ms·rtc_p99_ms 두 줄을 report.txt 에 더하세요.
- Opus 미디어 트랙으로 소리를 보낸다 — /root/rt/voice/voice.py 에 async rtc_audio(path, seconds=2.0) 를 추가하세요. aiortc.MediaStreamTrack 을 상속한 오디오 트랙이 frames(path, 20) 의 앞 seconds 초를 20ms 마다 av.AudioFrame(s16, mono, 16000Hz, pts 는 표본 단위)으로 내놓게 하고, 같은 프로세스의 두 피어 사이에서 addTrack 으로 보냅니다. 받는 쪽은 track 이벤트로 받은 트랙에서 recv 를 계속 불러 프레임 수와 표본율을 셉니다. {"codec": 응답 SDP 의 오디오 rtpmap 첫 코덱(예: opus/48000/2), "sent": 보낸 프레임 수, "received": 받은 프레임 수, "sample_rate": 받은 프레임의 표본율, "duration": 받은 첫 프레임부터 마지막 프레임까지의 초} 를 돌려줍니다.
- 재생 버퍼를 얼마로 둘지 계산한다 — /root/rt/voice/voice.py 에 playout(arrivals_ms, frame_ms, buffer_ms) 와 min_buffer(arrivals_ms, frame_ms, max_late) 를 추가하세요. arrivals_ms 는 순번 i 의 도착 시각(밀리초, 잃었으면 None)입니다. 재생 기준점은 처음 도착한 프레임 k 의 도착 시각에서 k × frame_ms 를 뺀 값이고, i 번째 프레임의 재생 마감은 기준점 + buffer_ms + i × frame_ms 입니다. playout 은 {"late": 마감보다 늦게 도착한 수, "lost": None 의 수} 를 돌려줍니다. min_buffer 는 0 부터 10ms 씩 늘려 가며, 늦은 수 ÷ 도착한 수가 max_late 이하가 되는 가장 작은 buffer_ms 를 돌려줍니다(1000 까지 없으면 None).
참고
- 작업 폴더는 /root/rt/voice 입니다. mkdir -p /root/rt/voice 로 먼저 만드세요.
- 측정기는 /opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py ws 와 rtc 이고, 여러분의 함수를 불러 씁니다. 메아리 서버와 멈추는 중계기는 측정기가 스스로 띄웁니다.
- 재료 WAV 는 /opt/fixtures/rt/voice/make_wav.py 가 결정적으로 만든 16kHz 모노 3초 파일입니다. 사람 목소리가 아니라 배음과 음절 포락선으로 흉내 낸 소리입니다.
- 흔한 실수 두 가지입니다. 보낼 때마다 frame_ms 만큼 잠들어 준비 시간이 누적되는 것, 그리고 오디오 트랙의 recv 가 잠들지 않아 몇 초 분량을 한순간에 내보내는 것입니다.
- 파이썬은 반드시 /opt/rt-lab/bin/python 으로 실행합니다. 이 실습의 라이브러리는 그 가상환경에만 들어 있고, 그냥 python3 로 돌리면 ModuleNotFoundError 가 납니다. alias rpy=/opt/rt-lab/bin/python 처럼 줄여 두면 편합니다.
- 실습 파드는 바깥으로 나가는 연결이 막혀 있습니다. 모든 통신은 같은 파드 안의 127.0.0.1 에서 일어나며, 설치나 다운로드는 필요 없습니다.
- 채점기는 코드를 별도 프로세스로 불러 실제 연결을 맺어 봅니다. 예시 파일은 함수 틀일 뿐이라 그대로 두면 통과하지 않습니다. 앞 단계에서 완성한 함수는 지우지 마세요.
- 실습 세션이 끝나면 /root 의 파일은 남지 않습니다. 필요한 코드는 끝내기 전에 따로 보관하세요.
WAV 를 20ms 프레임으로 자른다
/root/rt/voice/voice.py 에 frames(path, frame_ms=20) 를 만드세요. wave 모듈로 16비트 모노 WAV 를 읽어 frame_ms 길이의 bytes 조각 list 를 돌려줍니다. 조각 하나의 바이트 수는 표본율 × frame_ms / 1000 × 2 입니다. 마지막 조각이 모자라면 0 으로 채웁니다. 모노 16비트가 아니면 ValueError 입니다. 재료는 /opt/fixtures/rt/voice/speech16k.wav (16kHz, 3초) 입니다.
음성 코덱과 ASR 은 대부분 10·20·30ms 단위로 소리를 받습니다. 16kHz 에서 20ms 는 320 표본, 640 바이트입니다. 마지막 조각을 버리면 말끝이 잘립니다.
실제 시간에 맞춰 보낸다
/root/rt/voice/voice.py 에 async send_paced(send, frames, frame_ms, prepare=None) 를 추가하세요. i 번째 프레임을 시작 시각 + i × frame_ms 에 맞춰 보냅니다. 그 시각이 되면 prepare 가 있으면 prepare(frame) 을 불러 그 결과를, 없으면 frame 을 await send(...) 로 넘깁니다. 각 프레임을 보낸 시각을 시작 시각 기준 초로 담은 list 를 돌려줍니다. 채점기는 한 번에 5ms 걸리는 prepare 를 줍니다.
보낼 때마다 frame_ms 만큼 잠들면 준비에 쓴 시간이 매번 더해져, 50프레임이면 수백 ms 가 밀립니다. 마감 시각을 절대 시각으로 계산하고 남은 만큼만 잠드세요. 한꺼번에 몰아 보내도 안 됩니다 — 받는 쪽 버퍼가 넘칩니다.
평균이 아니라 백분위와 지터로 본다
/root/rt/voice/voice.py 에 percentiles(samples, ps=(50, 95, 99)) 와 jitter(transit_ms) 를 추가하세요. percentiles 는 최근접 순위(nearest-rank) 방식으로 p 마다 정렬한 값의 ceil(p/100 × n) 번째를 골라 {p: 값} dict 를 돌려주고, samples 가 비면 ValueError 입니다. jitter 는 RFC 3550 의 도착 간격 지터 추정기로, 연속한 두 패킷의 전송 시간 차 D 의 절댓값으로 J = J + (|D| - J) / 16 을 되풀이한 최종 J 를 돌려줍니다(J 는 0 에서 시작하고, 값이 하나뿐이면 0).
실시간 채널에서 사용자가 느끼는 것은 평균이 아니라 가끔 오는 긴 지연입니다. 1% 의 프레임이 300ms 늦으면 평균은 거의 안 변하지만 대화는 끊깁니다. 지터는 도착이 얼마나 들쭉날쭉한지를 나타내고 재생 버퍼 크기를 정하는 근거가 됩니다.
WebSocket 으로 보내고 왕복을 잰다
/root/rt/voice/voice.py 에 async ws_measure(url, frames, frame_ms) 를 추가하세요. url 에 붙어 프레임마다 앞에 struct.pack("!IQ", 순번, time.monotonic_ns()) 12바이트를 붙여 send_paced 로 보내고, 메아리를 받을 때마다 지금 시각과 머리의 시각 차로 왕복 밀리초를 셉니다. 다 보낸 뒤 1초 안에 오지 않은 것은 잃은 것으로 칩니다. {"sent": 보낸 수, "received": 받은 수, "rtt_ms": 받은 순서의 왕복 list, "p50": 값, "p99": 값} 을 돌려줍니다. 그다음 /opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py ws 를 돌려 출력의 ws_p50_ms·ws_p99_ms·ws_stall_p99_ms 세 줄을 /root/rt/voice/report.txt 에 적으세요.
보내는 일과 받는 일을 한 루프에서 번갈아 하면 받기를 기다리는 동안 다음 프레임이 늦습니다. 받는 쪽을 asyncio 태스크로 따로 두세요. bench 의 두 번째 측정은 TCP 가 한 번 300ms 멈추는 경우입니다. 잃은 것이 없는데도 p99 가 튀는 이유를 설명할 수 있어야 합니다.
WebRTC 데이터 채널로 같은 일을 한다
/root/rt/voice/voice.py 에 async rtc_measure(frames, frame_ms) 를 추가하세요. 한 프로세스 안에 ICE 서버 목록을 비운 aiortc 피어 둘을 만들어 제안·응답을 직접 건네고, 제안 쪽이 ordered=False, maxRetransmits=0 인 데이터 채널을 엽니다. 응답 쪽은 받은 메시지를 그대로 돌려주고, 제안 쪽은 ws_measure 와 같은 머리·같은 방식으로 재서 같은 모양의 dict 를 돌려줍니다. 그다음 /opt/rt-lab/bin/python /opt/fixtures/rt/voice/bench.py rtc 를 돌려 rtc_p50_ms·rtc_p99_ms 두 줄을 report.txt 에 더하세요.
같은 프로세스라 시그널링 서버가 필요 없습니다. 한 피어의 localDescription 을 다른 피어의 setRemoteDescription 에 곧바로 넘기면 됩니다. 채널이 열리기 전에 보내면 사라집니다.
Opus 미디어 트랙으로 소리를 보낸다
/root/rt/voice/voice.py 에 async rtc_audio(path, seconds=2.0) 를 추가하세요. aiortc.MediaStreamTrack 을 상속한 오디오 트랙이 frames(path, 20) 의 앞 seconds 초를 20ms 마다 av.AudioFrame(s16, mono, 16000Hz, pts 는 표본 단위)으로 내놓게 하고, 같은 프로세스의 두 피어 사이에서 addTrack 으로 보냅니다. 받는 쪽은 track 이벤트로 받은 트랙에서 recv 를 계속 불러 프레임 수와 표본율을 셉니다. {"codec": 응답 SDP 의 오디오 rtpmap 첫 코덱(예: opus/48000/2), "sent": 보낸 프레임 수, "received": 받은 프레임 수, "sample_rate": 받은 프레임의 표본율, "duration": 받은 첫 프레임부터 마지막 프레임까지의 초} 를 돌려줍니다.
데이터 채널은 바이트를 나르고, 미디어 트랙은 코덱과 RTP 타임스탬프와 SRTP 를 거쳐 소리를 나릅니다. WebRTC 의 Opus 는 입력이 16kHz 여도 SDP 에 늘 opus/48000/2 로 적히고 받는 쪽은 48kHz 로 풀어 냅니다. ASR 앞에서 다시 16kHz 로 내려야 한다는 뜻입니다. recv 가 스스로 잠들지 않으면 3초 분량이 한순간에 나갑니다.
재생 버퍼를 얼마로 둘지 계산한다
/root/rt/voice/voice.py 에 playout(arrivals_ms, frame_ms, buffer_ms) 와 min_buffer(arrivals_ms, frame_ms, max_late) 를 추가하세요. arrivals_ms 는 순번 i 의 도착 시각(밀리초, 잃었으면 None)입니다. 재생 기준점은 처음 도착한 프레임 k 의 도착 시각에서 k × frame_ms 를 뺀 값이고, i 번째 프레임의 재생 마감은 기준점 + buffer_ms + i × frame_ms 입니다. playout 은 {"late": 마감보다 늦게 도착한 수, "lost": None 의 수} 를 돌려줍니다. min_buffer 는 0 부터 10ms 씩 늘려 가며, 늦은 수 ÷ 도착한 수가 max_late 이하가 되는 가장 작은 buffer_ms 를 돌려줍니다(1000 까지 없으면 None).
재생 버퍼는 늦게 온 프레임을 기다려 주는 대신 모든 소리를 그만큼 늦춥니다. 너무 작으면 끊기고 너무 크면 대화가 굼떠집니다. 지터가 큰 망일수록 같은 끊김 비율을 지키는 데 더 큰 버퍼가 필요합니다.