전화 음성에서 웹 자막까지 6 — PCM 전달과 Agora 원격 녹음
별도 UDP PCM 수신기와 Agora 구독 수신기를 구분하고 20ms에서 10ms로 나누는 어댑터를 읽습니다. 로컬 WAV, SDK 게시, 원격 observer 녹음이 증명하는 범위를 비교합니다.
목차(26개 항목)
1. 20ms PCM 320바이트를 다시 계산한다
2. 개념 하나: `receive_pcm.py`는 두 번째 Gateway가 아니다
3. 개념 하나: 현재 Agora adapter는 20ms를 두 개의 10ms로 나눈다
4. 개념 하나: 실제 송신 API는 `push_audio_pcm_data()`다
- 5. 개념 하나: callback observer는 비동기 결과를 받는다
6. 개념 하나: 로컬 WAV와 원격 WAV는 증명 범위가 다르다
7. `samples_per_channel`을 바이트로 착각하지 않는다
8. 검증 스크립트 둘의 역할을 구분한다
- 9. 증거 사다리로 결과를 읽는다
- 11. 확인 문제
- 구현 근거와 공식 참고 자료
앞 글에서 Python Gateway가 RTP를 20ms PCM 프레임으로 바꾸는 과정을 읽었습니다. 이제 같은 PCM이 로컬 UDP 진단기, Agora 송신 adapter, WAV recorder로 어떻게 갈라지는지 확인합니다.
코드·JSON 조회에는 별도 원본 프로젝트가 필요합니다. 블로그 저장소에는
pbx_gateway/소스와 증거 파일이 포함돼 있지 않습니다. 원본이 없다면 파일 조회를 건너뛰고 본문 예시와 계산을 따라가세요. 실습 준비 조건을 먼저 확인하세요.
이 글의 핵심은 “녹음 파일이 생겼다”를 성공의 한 문장으로 뭉개지 않는 것입니다.
현재 PBX 훈련에서는 실제 패킷 캡처와 새 실통화 검증을 아직 수행하지 않았습니다. 아래 실습은 계산과 코드 읽기를 중심으로 하며, 원격 수신 결과는 저장소에 있는 검증 코드와 과거 검증의 범위를 설명합니다.
시리즈: 전체 지도 · 이전: Python Gateway 코드 읽기 · 6/8 PCM 전달과 녹음 · 다음: Agora STT와 Signaling 자막
1. 20ms PCM 320바이트를 다시 계산한다
현재 Gateway의 기본 PCM은 8kHz, mono, signed 16-bit little-endian입니다.
같은 오디오를 10ms로 나누면 절반입니다.
오프라인 실습 1
실행 위치: Mac 터미널 또는 Ubuntu VM, Python 3가 있는 어느 쪽이든 가능합니다.
기대 출력:
해석: 1은 mono 채널 수이고 2는 S16 PCM의 샘플당 바이트 수입니다. 80 samples_per_channel을 80바이트라고 읽으면 안 됩니다.
2. 개념 하나: receive_pcm.py는 두 번째 Gateway가 아니다
Agora를 붙이기 전에는 디코딩한 PCM이 별도 UDP 프로그램까지 도착하는지 확인할 수 있습니다.
receive_pcm.py에는 RTP parser, G.711 decoder, ARI 연결이 없습니다. 두 번째 Gateway가 아니라 PCM sink를 확인하는 계수기입니다.
실제 수신 루프는 단순합니다.
누적 시간을 total_bytes / 16000으로 계산합니다.
코드 읽기 실습 2
실행 위치: Ubuntu VM의 pbx_gateway 저장소. 이번 블로그 작성에서는 프로세스를 실행하지 않습니다.
기대 결과:
UDPPCMSink는remote_addr=(host, port)로 목적지를 정한다.receive_pcm.py는bind(("127.0.0.1", 60002))로 그 목적지에서 받는다.- 데이터그램 내용은 파싱하지 않고 길이와 누적량만 센다.
해석: 수신기에서 320바이트 프레임이 계속 보이면 RTP → PCM → UDP 60002 경계는 확인됩니다. Agora 송수신이나 자막은 아직 증명되지 않습니다.
현재 설정은 Agora 출력과 UDP PCM 출력을 동시에 켜지 못하게 검사합니다. 어느 sink의 결과인지 모호해지는 것을 막는 명시적 선택입니다.
3. 개념 하나: 현재 Agora adapter는 20ms를 두 개의 10ms로 나눈다
Gateway 내부의 표준 프레임은 20ms입니다. 현재 AgoraPCMSink adapter는 이를 10ms 두 조각으로 나눠 10ms 간격으로 보냅니다.
8kHz에서는 ten_ms_bytes가 160입니다.
bytearray()는 SDK에 넘길 변경 가능한 바이트 배열을 만드는 처리입니다. 재인코딩이 아닙니다.
여기서 범위를 정확히 말해야 합니다.
20ms를 10ms 두 조각으로 나누는 것은 이 저장소의 현재 Server Gateway adapter 구현과 pacing 선택입니다. 이를 모든 Agora SDK와 모든 오디오 API의 유일한 프레임 길이 규칙으로 일반화하면 안 됩니다.
코드 읽기 실습 3
실행 위치: Ubuntu VM의 pbx_gateway 저장소.
확인할 부분:
- 입력 프레임 크기는
sample_rate * 2 // 50, 즉 20ms로 검사합니다. - worker queue의 최대 크기는 1입니다.
- native SDK 호출과
sleep은asyncio미디어 루프가 아니라 전용 thread에서 실행합니다. - 100ms 이상 뒤처지면 과거 schedule을 급하게 따라잡지 않고 현재 시각으로 기준을 다시 잡습니다.
트레이드오프: 작은 queue는 지연이 쌓이는 것을 막지만, worker가 느리면 Agora PCM worker queue is full 오류가 빨리 드러납니다.
4. 개념 하나: 실제 송신 API는 push_audio_pcm_data()다
현재 Gateway 송신 경계는 다음 한 줄입니다.
| 인자 | 현재 값 | 의미 |
|---|---|---|
data | 10ms bytearray | S16LE PCM 바이트 |
sample_rate | 8,000 또는 16,000 | 초당 샘플 수 |
| 세 번째 인자 | 1 | mono 채널 수 |
현재 송신 코드는 PcmAudioFrame 객체를 만들지 않습니다. 다른 SDK 예제의 frame class를 이 코드가 사용하는 것처럼 설명하면 안 됩니다.
반환값이 음수가 아니면 이 Python 호출이 SDK 송신 경계에 PCM을 제출했다는 뜻입니다. 다음까지 자동으로 증명하지는 않습니다.
- Agora 네트워크를 지나갔는가
- 다른 UID가 구독했는가
- callback으로 PCM이 돌아왔는가
- 소리가 0이 아닌가
- STT와 Signaling 자막까지 성공했는가
Agora 연결 흐름
AgoraPCMSink.create() 이후 worker는 다음 순서로 움직입니다.
Gateway RTC UID는 SIP 계정, 전화 내선, RTP SSRC와 별개의 식별자입니다. 예를 들어 RTC UID 70001을 쓴다고 전화 내선이 70001이 되는 것은 아닙니다.
5. 개념 하나: callback observer는 비동기 결과를 받는다
connect() 호출 반환은 연결 요청을 SDK가 받아들였는지 알려줍니다. 실제 연결 성공은 on_connected callback으로 확인합니다. 게시도 on_audio_track_publish_success와 실패 callback을 따로 둡니다.
이 패턴은 비동기 SDK에서 중요합니다.
한 단계의 성공을 다음 단계의 성공으로 확대하지 않는 것이 디버깅의 기본입니다.
6. 개념 하나: 로컬 WAV와 원격 WAV는 증명 범위가 다르다
Gateway 로컬 WAV
recording_enabled=true이면 WaveRecorder가 RTP를 디코딩한 직후의 PCM을 파일에 씁니다.
| 설정 | 의미 |
|---|---|
channel 1 | mono |
sample width 2 | signed 16-bit PCM |
| rate | 8,000 또는 16,000Hz |
compression NONE | PCM을 압축하지 않음 |
디스크 쓰기는 별도 daemon thread에서 처리합니다. 녹음 queue가 넘치면 recording_queue_overflow로 녹음을 불완전 처리하지만, LiveOutput은 계속 동작할 수 있습니다.
로컬 WAV가 증명하는 것:
로컬 WAV가 증명하지 못하는 것:
Agora 원격 WAV
verify_agora_receive.py는 별도 RTC UID로 같은 channel에 들어가 Gateway UID의 오디오를 구독합니다. 여러 사용자가 섞이기 전 callback에서 특정 UID인지 확인하고 WAV에 씁니다.
실제 구현은 다음을 검사합니다.
- UID가 목표 Gateway UID인가
- mono인가
bytes_per_sample == 2인가- sample rate가 8kHz 또는 16kHz인가
- buffer가 비어 있지 않고 2바이트 배수인가
- 첫 frame 이후 format이 바뀌지 않았는가
- 0이 아닌 샘플이 하나 이상 있는가
첫 유효 callback의 metadata로 WAV header를 정하므로, 수신 format을 추측해 잘못 기록하지 않습니다.
7. samples_per_channel을 바이트로 착각하지 않는다
원격 callback frame에서 다음 metadata를 볼 수 있습니다.
samples_per_channel: 채널 하나에 들어 있는 샘플 개수channels: 채널 수bytes_per_sample: 샘플 하나의 바이트 수
전체 바이트는 곱해서 구합니다.
8kHz·10ms·mono·S16이면:
스테레오라면 samples_per_channel은 여전히 80이고 전체 크기만 80 × 2 × 2 = 320 bytes가 됩니다.
오프라인 실습 4
실행 위치: Mac 터미널 또는 Ubuntu VM.
기대 출력은 mono 160바이트, stereo 320바이트입니다.
8. 검증 스크립트 둘의 역할을 구분한다
verify_agora_publish.py
이 스크립트는 마이크 대신 440Hz tone을 만들고, A-law로 인코딩한 뒤 RTP header를 붙여 rtp-only Gateway로 보냅니다.
250개 × 20ms는 5초입니다. 스크립트는 packets_received == 250, output_frames == 250, sink_errors == 0, live_drops == 0을 검사합니다.
이 성공이 뜻하는 것: 합성 RTP 5초가 Gateway를 거쳐 SDK 송신 경계까지 제출됐습니다.
이 성공만으로 알 수 없는 것: 원격 사용자가 그 PCM을 실제로 받았는지는 별도 확인이 필요합니다.
verify_agora_receive.py
이 스크립트는 다른 RTC UID로 같은 channel에 접속하고 Gateway UID를 구독합니다.
최종 JSON은 connection, subscription, frame/byte 수, nonzero_samples, format, audio seconds와 artifact 경로를 남깁니다. frame이 0개이거나 모든 샘플이 0이면 성공 처리하지 않습니다.
이 스크립트는 Agora 원격 PCM 수신을 증명하지만, 실제 Linphone 마이크 경로 전체를 자동으로 증명하지는 않습니다. 합성 RTP를 사용했는지 실제 통화를 사용했는지 입력 조건을 함께 기록해야 합니다.
이 글을 작성하는 세션에서는 token·환경 파일을 읽지 않았고 두 검증 스크립트나 실통화를 실행하지 않았습니다. 아래 명령은 PBX 실습 세션에서 자격 증명과 실행 시점을 관리한 뒤 사용할 후속 실습 후보입니다.
실행 위치: Ubuntu VM의 pbx_gateway 저장소.
별도 터미널의 같은 위치에서:
원격 수신기를 먼저 연결해야 게시 시작 시점을 놓치지 않습니다. 실제 환경에서는 저장소 안내에 따라 가상환경과 자격 증명을 먼저 준비해야 하며, token 값을 터미널 출력이나 블로그에 복사하지 않습니다.
9. 증거 사다리로 결과를 읽는다
| 확인 방법 | 증명하는 것 | 아직 증명하지 못하는 것 |
|---|---|---|
media_metrics | RTP 수신·디코딩·출력 진행 | Agora 원격 수신 |
| Gateway 로컬 WAV | RTP→PCM 결과를 로컬 저장 | Agora 네트워크 통과 |
receive_pcm.py | UDP 60002 raw PCM 전달 | Agora 송수신 |
verify_agora_publish.py | 합성 RTP→Gateway→SDK 제출 | 원격 PCM 도착 |
verify_agora_receive.py | 별도 UID의 Agora PCM 수신·WAV 저장 | 실제 전화 마이크 전체 경로 |
| 실제 전화 + 원격 수신 | 마이크부터 Agora 수신까지 | STT·Signaling 자막 전달 |
| 웹 Signaling 자막 | Agora STT 자막 메시지가 화면까지 도착 | 웹 RTC 음성 재생 |
실무에서는 아래쪽 증거 하나만 보지 않고 위에서부터 이어지는지 확인합니다. 그래야 실패 구간을 좁힐 수 있습니다.
10. 흔한 오류와 읽는 법
매번 320바이트가 오지 않는다
먼저 sample rate와 frame duration을 확인합니다. 16kHz·20ms S16 mono라면 640바이트입니다. Agora adapter 내부의 10ms 조각은 8kHz에서 160바이트, 16kHz에서 320바이트입니다.
push_audio_pcm_data()가 성공했는데 원격 WAV가 없다
같은 App ID와 RTC channel인지, UID와 token이 맞는지, 연결·게시 callback이 왔는지, 원격 구독 결과가 성공인지 순서대로 봅니다. SDK 제출 성공을 원격 수신 성공으로 해석하지 않습니다.
원격 callback은 오는데 WAV가 비었다
목표 UID가 맞는지, before-mixing callback인지, format 검사에서 거절되지 않았는지, nonzero_samples가 0인지 봅니다.
로컬 WAV 실패 때문에 실시간 전송도 멈췄다고 생각한다
현재 구현은 녹음 queue와 LiveOutput을 분리합니다. recording_status, recording_error와 output_frames, sink_errors를 각각 확인해야 합니다.
10ms가 모든 Agora SDK의 절대 규칙이라고 말한다
이 글에서 확인한 것은 현재 저장소의 Python Server Gateway adapter가 20ms 입력을 두 개의 10ms 조각으로 나누고 pacing한다는 사실입니다. 제품·SDK·API가 달라지면 해당 공식 문서와 실제 method signature를 다시 확인합니다.
11. 확인 문제
- 8kHz S16LE mono의 20ms와 10ms는 각각 몇 샘플, 몇 바이트인가요?
push_audio_pcm_data(data, sample_rate, 1)의1은 무엇인가요?receive_pcm.py가 두 번째 Gateway가 아닌 이유는 무엇인가요?- Gateway 로컬 WAV와 Agora 원격 WAV는 각각 어디까지 증명하나요?
verify_agora_publish.py가 성공해도verify_agora_receive.py가 필요한 이유는 무엇인가요?- 20ms→10ms 분할을 모든 Agora SDK 규칙으로 일반화하면 안 되는 이유는 무엇인가요?
이 여섯 질문에 답할 수 있으면 다음 글에서 RTC 음성과 별도의 Signaling 자막 경로, UID와 channel의 역할을 구분할 준비가 된 것입니다.
구현 근거와 공식 참고 자료
이 글의 실제 구현 근거는 원본 저장소의 다음 파일입니다.
pbx_gateway/gateway/outputs.py:UDPPCMSink,LiveOutput,WaveRecorderpbx_gateway/gateway/agora_output.py: Agora 연결, observer, 20ms→10ms 분할,push_audio_pcm_data()pbx_gateway/gateway/__main__.py: sink 선택과 녹음 분기pbx_gateway/scripts/receive_pcm.py: raw PCM UDP 계수기pbx_gateway/scripts/verify_agora_publish.py: 합성 RTP의 SDK 제출 검증pbx_gateway/scripts/verify_agora_receive.py: 별도 UID 원격 PCM callback과 WAV 검증pbx_gateway/README.md: 현재 구현과 과거 검증의 범위
공식 자료: