RSS듀오랩스
모바일 앱

WebSocket 음성 스트리밍: PCM과 Speex로 사용자 음성을 보낸 방법

작성자
듀오랩스 대표·7분 읽기

실시간 음성 전송은 녹음 파일 업로드와 다릅니다. 사용자가 말하는 동안 마이크 샘플을 계속 받아 압축하고, 순서를 유지한 채 서버로 보내야 합니다. 어느 한 구간이 늦어지면 음성이 끊기거나 대화가 과거로 밀립니다.

iOS에서 16비트, 16 kHz, 모노 PCM을 수집하고 Speex로 인코딩해 WebSocket으로 전송하는 흐름을 기준으로 살펴보겠습니다.

마이크에서는 먼저 PCM이 나옵니다

iOS에서는 AVAudioEngine의 입력 노드에 탭을 설치해 마이크 버퍼를 받을 수 있습니다.

let audioEngine = AVAudioEngine()
let inputNode = audioEngine.inputNode
let format = AVAudioFormat(
    commonFormat: .pcmFormatInt16,
    sampleRate: 16_000,
    channels: 1,
    interleaved: false
)

inputNode.installTap(
    onBus: 0,
    bufferSize: 2_048,
    format: format
) { buffer, time in
    guard let samples = buffer.int16ChannelData?[0] else { return }
    // PCM 복사, 프레임 재구성, Speex 인코딩과 전송
}

Apple의 installTap은 지정한 버스의 오디오 버퍼를 콜백으로 관찰하는 API입니다. AVAudioPCMBuffer의 int16ChannelData는 포맷이 16비트 정수일 때 실제 샘플 포인터를 제공합니다.

예제의 입력 조건은 다음과 같습니다.

항목 값
샘플 형식 16비트 정수 PCM
샘플레이트 16 kHz
채널 모노
인터리빙 비인터리브
요청 버퍼 크기 2,048 프레임

16 kHz에서 2,048 샘플은 계산상 약 128 ms입니다. 다만 installTap에서 요청한 버퍼 하나를 네트워크 메시지 하나로 그대로 보내면 된다는 뜻은 아닙니다.

캡처 버퍼와 코덱 프레임의 경계는 다릅니다

Speex는 음성에 맞춘 손실 압축 코덱입니다. 공식 Speex 코덱 설명은 8 kHz, 16 kHz, 32 kHz 모드를 각각 협대역, 광대역, 초광대역으로 구분합니다. 16 kHz PCM에는 광대역 모드가 대응합니다.

Speex API의 기본 인코딩 단위는 20 ms입니다. 16 kHz에서는 한 코덱 프레임이 320 샘플입니다. 마이크 탭의 요청 크기가 2,048 샘플이라면 두 경계는 정확히 맞아떨어지지 않습니다.

마이크 탭 요청 크기    2,048 samples
Speex 한 프레임          320 samples
비율                    6.4 frames

마이크 콜백에서 받은 PCM을 누적하고 320 샘플씩 잘라 인코더에 넘긴 뒤, 남은 샘플은 다음 콜백과 이어야 합니다. 캡처 버퍼, 코덱 프레임, WebSocket 메시지를 같은 단위로 취급하면 샘플이 누락되거나 프레임 중간이 잘릴 수 있습니다.

오디오 콜백은 짧게 끝내야 합니다

마이크 입력을 확인하는 단계에서는 PCM을 배열로 바꾸어 출력하는 코드가 편합니다. 실시간 전송 경로에서는 배열 생성, 인코딩과 네트워크 대기를 오디오 콜백 안에 모두 넣지 않는 편이 좋습니다.

Audio callback
  -> PCM을 필요한 만큼 복사
  -> 전용 직렬 큐에 전달
  -> 코덱 프레임 단위로 재구성
  -> Speex 인코딩
  -> WebSocket binary message 전송

콜백은 다음 버퍼를 받을 수 있도록 빠르게 반환하고, 순서가 중요한 후속 작업은 별도 직렬 큐에서 처리합니다. 동시에 큐의 최대 크기도 정해야 합니다. 네트워크가 마이크 입력보다 느린 상태에서 오래된 음성을 계속 쌓으면 데이터는 보존되지만 대화 지연은 계속 늘어납니다.

실시간 음성에서는 무한 버퍼보다 지연 상한이 중요합니다. 상한을 넘으면 오래된 프레임을 처리할지, 발화를 중단하고 재시도를 요청할지 제품의 대화 규칙에 맞춰 결정해야 합니다.

WebSocket에는 압축된 음성을 바이너리로 보냅니다

일반 HTTP 업로드는 녹음이 끝난 파일을 보내기에 적합합니다. 사용자가 말하는 동안 음성을 흘려보내려면 연결을 유지한 채 메시지를 연속해서 보낼 수 있는 통신 방식이 필요합니다.

WebSocket RFC 6455는 텍스트 프레임과 바이너리 프레임을 구분합니다. Speex로 인코딩한 음성은 바이너리 데이터이므로 문자열이나 Base64로 바꾸지 않고 binary message로 보내는 편이 자연스럽습니다. 연결 제어와 서버 상태 같은 작은 메타데이터는 텍스트 메시지나 별도의 제어 구조로 분리할 수 있습니다.

메시지 경계도 코덱 프레임과 함께 설계해야 합니다. 메시지 하나에 프레임을 몇 개 넣는지, 순서 번호와 발화 ID를 어떻게 붙이는지, 마지막 프레임을 어떻게 알리는지가 서버의 디코딩과 재연결 처리에 영향을 줍니다.

WebSocket과 WebRTC는 서로 다른 역할을 맡을 수 있습니다

한 번의 AI 대화에서도 입력과 출력의 전송 조건은 다를 수 있습니다.

방향 데이터 전송 경로
앱에서 서버 사용자의 질문 음성 PCM 수집, Speex 압축, WebSocket 스트리밍
서버에서 앱 생성된 영상과 합성 음성 WebRTC 미디어 전달과 앱 재생

Speex를 WebRTC의 표준 음성 코덱으로 사용한다는 의미는 아닙니다. WebRTC의 필수 음성 코덱은 Opus와 G.711 계열입니다. 이 구성에서 Speex는 WebRTC 미디어 트랙이 아니라 별도의 사용자 음성 입력 경로에 있습니다.

재연결에는 발화 상태 복원이 필요합니다

실시간 음성에서 재연결은 소켓만 다시 여는 것으로 끝나지 않습니다. 연결이 끊어진 동안 쌓인 프레임을 보낼지 버릴지, 서버가 이전 발화를 어디까지 받았는지, 사용자가 다시 질문해야 하는지를 함께 결정해야 합니다.

발화마다 식별자를 부여하고 프레임 순서를 기록하면 중복과 누락을 판단하기 쉬워집니다. 연결 상태와 함께 마지막으로 서버가 확인한 순서를 관리하면 재연결 이후의 처리 기준도 명확해집니다.

마이크가 만드는 PCM의 시간, Speex가 요구하는 프레임의 시간, 네트워크가 감당하는 전송 시간을 같은 흐름으로 맞춰야 합니다. 음성 파일을 보내는 기능과 사용자가 말하는 지금을 보내는 기능의 차이는 이 시간 경계에서 생깁니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.