블로그 목록
실시간 통신18분 읽기

전화 음성에서 웹 자막까지 6 — PCM 전달과 Agora 원격 녹음

별도 UDP PCM 수신기와 Agora 구독 수신기를 구분하고 20ms에서 10ms로 나누는 어댑터를 읽습니다. 로컬 WAV, SDK 게시, 원격 observer 녹음이 증명하는 범위를 비교합니다.

PBX 입문PCMAgoraRTCWAVPython
목차(26개 항목)
  1. 1. 20ms PCM 320바이트를 다시 계산한다
    1. 오프라인 실습 1
  2. 2. 개념 하나: `receive_pcm.py`는 두 번째 Gateway가 아니다
    1. 코드 읽기 실습 2
  3. 3. 개념 하나: 현재 Agora adapter는 20ms를 두 개의 10ms로 나눈다
    1. 코드 읽기 실습 3
  4. 4. 개념 하나: 실제 송신 API는 `push_audio_pcm_data()`다
    1. Agora 연결 흐름
  5. 5. 개념 하나: callback observer는 비동기 결과를 받는다
  6. 6. 개념 하나: 로컬 WAV와 원격 WAV는 증명 범위가 다르다
    1. Gateway 로컬 WAV
    2. Agora 원격 WAV
  7. 7. `samples_per_channel`을 바이트로 착각하지 않는다
    1. 오프라인 실습 4
  8. 8. 검증 스크립트 둘의 역할을 구분한다
    1. `verify_agora_publish.py`
    2. `verify_agora_receive.py`
  9. 9. 증거 사다리로 결과를 읽는다
  10. 10. 흔한 오류와 읽는 법
    1. 매번 320바이트가 오지 않는다
    2. `push_audio_pcm_data()`가 성공했는데 원격 WAV가 없다
    3. 원격 callback은 오는데 WAV가 비었다
    4. 로컬 WAV 실패 때문에 실시간 전송도 멈췄다고 생각한다
    5. 10ms가 모든 Agora SDK의 절대 규칙이라고 말한다
  11. 11. 확인 문제
  12. 구현 근거와 공식 참고 자료

앞 글에서 Python Gateway가 RTP를 20ms PCM 프레임으로 바꾸는 과정을 읽었습니다. 이제 같은 PCM이 로컬 UDP 진단기, Agora 송신 adapter, WAV recorder로 어떻게 갈라지는지 확인합니다.

코드·JSON 조회에는 별도 원본 프로젝트가 필요합니다. 블로그 저장소에는 pbx_gateway/ 소스와 증거 파일이 포함돼 있지 않습니다. 원본이 없다면 파일 조회를 건너뛰고 본문 예시와 계산을 따라가세요. 실습 준비 조건을 먼저 확인하세요.

이 글의 핵심은 “녹음 파일이 생겼다”를 성공의 한 문장으로 뭉개지 않는 것입니다.

로컬 PCM 생성 ≠ SDK 제출 ≠ Agora 원격 수신 ≠ STT·자막 성공

현재 PBX 훈련에서는 실제 패킷 캡처와 새 실통화 검증을 아직 수행하지 않았습니다. 아래 실습은 계산과 코드 읽기를 중심으로 하며, 원격 수신 결과는 저장소에 있는 검증 코드와 과거 검증의 범위를 설명합니다.

시리즈: 전체 지도 · 이전: Python Gateway 코드 읽기 · 6/8 PCM 전달과 녹음 · 다음: Agora STT와 Signaling 자막


1. 20ms PCM 320바이트를 다시 계산한다

현재 Gateway의 기본 PCM은 8kHz, mono, signed 16-bit little-endian입니다.

샘플 수 = 8,000 samples/s × 0.020s = 160 samples
바이트 수 = 160 samples × 2 bytes = 320 bytes

같은 오디오를 10ms로 나누면 절반입니다.

샘플 수 = 8,000 × 0.010 = 80 samples
바이트 수 = 80 × 2 = 160 bytes
20ms PCM 320B
┌────────────────────┬────────────────────┐
│ 앞 10ms: 80샘플 160B │ 뒤 10ms: 80샘플 160B │
└────────────────────┴────────────────────┘

오프라인 실습 1

실행 위치: Mac 터미널 또는 Ubuntu VM, Python 3가 있는 어느 쪽이든 가능합니다.

python3 - <<'PY'
for milliseconds in (20, 10):
    samples = 8000 * milliseconds // 1000
    pcm_bytes = samples * 1 * 2
    print(milliseconds, "ms:", samples, "samples,", pcm_bytes, "bytes")
PY

기대 출력:

20 ms: 160 samples, 320 bytes
10 ms: 80 samples, 160 bytes

해석: 1은 mono 채널 수이고 2는 S16 PCM의 샘플당 바이트 수입니다. 80 samples_per_channel을 80바이트라고 읽으면 안 됩니다.


2. 개념 하나: receive_pcm.py는 두 번째 Gateway가 아니다

Agora를 붙이기 전에는 디코딩한 PCM이 별도 UDP 프로그램까지 도착하는지 확인할 수 있습니다.

프로그램 A: Python Gateway
  UDP 60000 RTP 수신
  → G.711 디코딩
  → 20ms PCM 생성
  → UDP 60002로 raw PCM 송신

프로그램 B: receive_pcm.py
  127.0.0.1:60002 bind
  → raw PCM 데이터그램 수신
  → 프레임 수·바이트 수만 출력

receive_pcm.py에는 RTP parser, G.711 decoder, ARI 연결이 없습니다. 두 번째 Gateway가 아니라 PCM sink를 확인하는 계수기입니다.

실제 수신 루프는 단순합니다.

# 실제 코드
sock.bind(("127.0.0.1", 60002))
while True:
    data, _ = sock.recvfrom(65535)
    frames += 1
    total_bytes += len(data)

누적 시간을 total_bytes / 16000으로 계산합니다.

8,000 samples/s × 2 bytes × 1 channel = 16,000 bytes/s

코드 읽기 실습 2

실행 위치: Ubuntu VM의 pbx_gateway 저장소. 이번 블로그 작성에서는 프로세스를 실행하지 않습니다.

cd /workspace/pbx_gateway
sed -n '1,80p' scripts/receive_pcm.py
sed -n '20,55p' gateway/outputs.py

기대 결과:

  • 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 간격으로 보냅니다.

# 실제 코드
ten_ms_bytes = self.sample_rate * 2 // 100

for offset in range(0, len(command.data), ten_ms_bytes):
    chunk = bytearray(command.data[offset : offset + ten_ms_bytes])
    self._backend.send(chunk, self.sample_rate)
    next_send_at += 0.010

8kHz에서는 ten_ms_bytes가 160입니다.

offset 0   → data[0:160]   → 앞 10ms
offset 160 → data[160:320] → 뒤 10ms

bytearray()는 SDK에 넘길 변경 가능한 바이트 배열을 만드는 처리입니다. 재인코딩이 아닙니다.

여기서 범위를 정확히 말해야 합니다.

20ms를 10ms 두 조각으로 나누는 것은 이 저장소의 현재 Server Gateway adapter 구현과 pacing 선택입니다. 이를 모든 Agora SDK와 모든 오디오 API의 유일한 프레임 길이 규칙으로 일반화하면 안 됩니다.

코드 읽기 실습 3

실행 위치: Ubuntu VM의 pbx_gateway 저장소.

cd /workspace/pbx_gateway
sed -n '235,330p' gateway/agora_output.py

확인할 부분:

  1. 입력 프레임 크기는 sample_rate * 2 // 50, 즉 20ms로 검사합니다.
  2. worker queue의 최대 크기는 1입니다.
  3. native SDK 호출과 sleep은 asyncio 미디어 루프가 아니라 전용 thread에서 실행합니다.
  4. 100ms 이상 뒤처지면 과거 schedule을 급하게 따라잡지 않고 현재 시각으로 기준을 다시 잡습니다.

트레이드오프: 작은 queue는 지연이 쌓이는 것을 막지만, worker가 느리면 Agora PCM worker queue is full 오류가 빨리 드러납니다.


4. 개념 하나: 실제 송신 API는 push_audio_pcm_data()다

현재 Gateway 송신 경계는 다음 한 줄입니다.

# 실제 코드
result = self.connection.push_audio_pcm_data(data, sample_rate, 1)
인자현재 값의미
data10ms bytearrayS16LE PCM 바이트
sample_rate8,000 또는 16,000초당 샘플 수
세 번째 인자1mono 채널 수

현재 송신 코드는 PcmAudioFrame 객체를 만들지 않습니다. 다른 SDK 예제의 frame class를 이 코드가 사용하는 것처럼 설명하면 안 됩니다.

반환값이 음수가 아니면 이 Python 호출이 SDK 송신 경계에 PCM을 제출했다는 뜻입니다. 다음까지 자동으로 증명하지는 않습니다.

  • Agora 네트워크를 지나갔는가
  • 다른 UID가 구독했는가
  • callback으로 PCM이 돌아왔는가
  • 소리가 0이 아닌가
  • STT와 Signaling 자막까지 성공했는가

Agora 연결 흐름

AgoraPCMSink.create() 이후 worker는 다음 순서로 움직입니다.

AgoraService 초기화
  ↓
LIVE_BROADCASTING connection 생성
  ↓
connection / publish observer 등록
  ↓
token·channel·UID로 connect
  ↓
on_connected callback 대기
  ↓
publish_audio()
  ↓
10ms PCM마다 push_audio_pcm_data(data, sample_rate, 1)

Gateway RTC UID는 SIP 계정, 전화 내선, RTP SSRC와 별개의 식별자입니다. 예를 들어 RTC UID 70001을 쓴다고 전화 내선이 70001이 되는 것은 아닙니다.


5. 개념 하나: callback observer는 비동기 결과를 받는다

connect() 호출 반환은 연결 요청을 SDK가 받아들였는지 알려줍니다. 실제 연결 성공은 on_connected callback으로 확인합니다. 게시도 on_audio_track_publish_success와 실패 callback을 따로 둡니다.

# 실제 구조를 축약
class ConnectionObserver:
    def on_connected(...):
        connected.set()

    def on_connection_failure(...):
        failed.set()

이 패턴은 비동기 SDK에서 중요합니다.

API 호출 성공 = 요청 접수
observer callback = 이후 상태 변화
원격 audio callback = 실제 구독 경로의 별도 증거

한 단계의 성공을 다음 단계의 성공으로 확대하지 않는 것이 디버깅의 기본입니다.


6. 개념 하나: 로컬 WAV와 원격 WAV는 증명 범위가 다르다

Gateway 로컬 WAV

recording_enabled=true이면 WaveRecorder가 RTP를 디코딩한 직후의 PCM을 파일에 씁니다.

# 실제 코드
wav.setparams((1, 2, self.rate, 0, "NONE", "not compressed"))
설정의미
channel 1mono
sample width 2signed 16-bit PCM
rate8,000 또는 16,000Hz
compression NONEPCM을 압축하지 않음

디스크 쓰기는 별도 daemon thread에서 처리합니다. 녹음 queue가 넘치면 recording_queue_overflow로 녹음을 불완전 처리하지만, LiveOutput은 계속 동작할 수 있습니다.

로컬 WAV가 증명하는 것:

Asterisk RTP → Gateway 수신 → G.711 decode → PCM → 로컬 파일

로컬 WAV가 증명하지 못하는 것:

Agora SDK 제출 → 네트워크 → 원격 UID 구독 → 원격 PCM callback

Agora 원격 WAV

verify_agora_receive.py는 별도 RTC UID로 같은 channel에 들어가 Gateway UID의 오디오를 구독합니다. 여러 사용자가 섞이기 전 callback에서 특정 UID인지 확인하고 WAV에 씁니다.

# 설명을 위한 축약 코드. 실제 구현은 format과 UID를 더 검사한다.
def on_playback_audio_frame_before_mixing(..., uid, frame, ...):
    if str(uid) != REMOTE_UID:
        return 1
    payload = bytes(frame.buffer)
    wav.writeframesraw(payload)
    return 1

실제 구현은 다음을 검사합니다.

  • 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: 샘플 하나의 바이트 수

전체 바이트는 곱해서 구합니다.

전체 bytes = samples_per_channel × channels × bytes_per_sample

8kHz·10ms·mono·S16이면:

80 × 1 × 2 = 160 bytes

스테레오라면 samples_per_channel은 여전히 80이고 전체 크기만 80 × 2 × 2 = 320 bytes가 됩니다.

오프라인 실습 4

실행 위치: Mac 터미널 또는 Ubuntu VM.

python3 - <<'PY'
cases = [
    {"samples_per_channel": 80, "channels": 1, "bytes_per_sample": 2},
    {"samples_per_channel": 80, "channels": 2, "bytes_per_sample": 2},
]
for case in cases:
    total = case["samples_per_channel"] * case["channels"] * case["bytes_per_sample"]
    print(case, "=>", total, "bytes")
PY

기대 출력은 mono 160바이트, stereo 320바이트입니다.


8. 검증 스크립트 둘의 역할을 구분한다

verify_agora_publish.py

이 스크립트는 마이크 대신 440Hz tone을 만들고, A-law로 인코딩한 뒤 RTP header를 붙여 rtp-only Gateway로 보냅니다.

합성 440Hz PCM
  → PCMA 인코딩
  → RTP 250개, 패킷당 20ms
  → Gateway
  → Agora SDK 송신 경계

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를 구독합니다.

Gateway RTC UID 70001 게시
  ↓ Agora RTC channel
검증 RTC UID 70002 구독
  ↓ before-mixing PCM callback
artifacts/agora-received.wav

최종 JSON은 connection, subscription, frame/byte 수, nonzero_samples, format, audio seconds와 artifact 경로를 남깁니다. frame이 0개이거나 모든 샘플이 0이면 성공 처리하지 않습니다.

이 스크립트는 Agora 원격 PCM 수신을 증명하지만, 실제 Linphone 마이크 경로 전체를 자동으로 증명하지는 않습니다. 합성 RTP를 사용했는지 실제 통화를 사용했는지 입력 조건을 함께 기록해야 합니다.

이 글을 작성하는 세션에서는 token·환경 파일을 읽지 않았고 두 검증 스크립트나 실통화를 실행하지 않았습니다. 아래 명령은 PBX 실습 세션에서 자격 증명과 실행 시점을 관리한 뒤 사용할 후속 실습 후보입니다.

실행 위치: Ubuntu VM의 pbx_gateway 저장소.

cd /workspace/pbx_gateway
python scripts/verify_agora_receive.py

별도 터미널의 같은 위치에서:

cd /workspace/pbx_gateway
python scripts/verify_agora_publish.py

원격 수신기를 먼저 연결해야 게시 시작 시점을 놓치지 않습니다. 실제 환경에서는 저장소 안내에 따라 가상환경과 자격 증명을 먼저 준비해야 하며, token 값을 터미널 출력이나 블로그에 복사하지 않습니다.


9. 증거 사다리로 결과를 읽는다

확인 방법증명하는 것아직 증명하지 못하는 것
media_metricsRTP 수신·디코딩·출력 진행Agora 원격 수신
Gateway 로컬 WAVRTP→PCM 결과를 로컬 저장Agora 네트워크 통과
receive_pcm.pyUDP 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. 확인 문제

  1. 8kHz S16LE mono의 20ms와 10ms는 각각 몇 샘플, 몇 바이트인가요?
  2. push_audio_pcm_data(data, sample_rate, 1)의 1은 무엇인가요?
  3. receive_pcm.py가 두 번째 Gateway가 아닌 이유는 무엇인가요?
  4. Gateway 로컬 WAV와 Agora 원격 WAV는 각각 어디까지 증명하나요?
  5. verify_agora_publish.py가 성공해도 verify_agora_receive.py가 필요한 이유는 무엇인가요?
  6. 20ms→10ms 분할을 모든 Agora SDK 규칙으로 일반화하면 안 되는 이유는 무엇인가요?

이 여섯 질문에 답할 수 있으면 다음 글에서 RTC 음성과 별도의 Signaling 자막 경로, UID와 channel의 역할을 구분할 준비가 된 것입니다.


구현 근거와 공식 참고 자료

이 글의 실제 구현 근거는 원본 저장소의 다음 파일입니다.

  • pbx_gateway/gateway/outputs.py: UDPPCMSink, LiveOutput, WaveRecorder
  • pbx_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: 현재 구현과 과거 검증의 범위

공식 자료:

© 2026 Frank Kim. All rights reserved.