작성일
Gem3Park 개발기 1 — AudioSocket과 Gemini Live로 인터폰 전화 받기
주차장 인터폰 전화를 AI가 받게 하려고 Asterisk AudioSocket과 Gemini Live API를 잇는 릴레이 서버를 만든 첫 열흘. 샘플레이트 변환, 20ms 재생 큐, 통화 종료 타이밍에서 막힌 지점을 정리했다.
Gem3Park 개발기 시리즈 1/8편. 전체 목록은 gem3park 태그에서 볼 수 있다.
시작: 전화를 받는 서버부터
2026년 2월 7일, 아파트 주차장 인터폰 전화를 AI가 받게 하는 일을 시작했다. 방문 차량의 동·호수와 방문 목적을 확인하고 출입 명령을 보내는 것까지가 목표였다. 당시에는 말을 알아듣고 대답까지 하는 일을 Gemini Live API 하나에 맡기는 구성이 가장 빨라 보였다.
처음 구성은 이랬다.
- Asterisk가 SIP 통화를 받고 AudioSocket으로 통화 음성을 TCP로 내보낸다.
- TypeScript로 쓴 릴레이가 통화 하나마다 세션을 만들고 Gemini Live와 WebSocket을 연다.
- Gemini가 돌려준 음성을 다시 Asterisk로 보낸다.
전화가 들어오면 AudioSocket이 통화 UUID를 보내고, 릴레이가 세션을 만들고, 시스템 프롬프트를 넣은 Gemini 연결이 준비되면 양방향 오디오가 흐른다.
샘플레이트가 서로 맞지 않는다
처음 막힌 곳은 오디오 규격이었다. 방향마다 숫자가 달랐다.
| 구간 | 형식 |
|---|---|
| Asterisk → 릴레이 | 8kHz 또는 16kHz PCM (통화 코덱에 따라) |
| 릴레이 → Gemini | 16kHz PCM을 Base64로 전송 |
| Gemini → 릴레이 | 24kHz PCM |
| 릴레이 → Asterisk | 통화 코덱과 같은 8kHz 또는 16kHz |
2월 8일에 8kHz를 16kHz로 올리는 함수를 넣었다. 처음 구현은 표본을 두 번씩 복사하는 가장 단순한 방식이었다. 당시 코드 주석에도 "음성 업스케일에는 충분하고, 품질이 필요하면 보간으로 바꿀 수 있다"고 적어 두었다. 음성 인식이 목적이라 일단 이 정도로 시작했다.
export function upsample8kTo16k(input: Buffer): Buffer {
const inputSamples = input.length / BYTES_PER_SAMPLE;
const output = Buffer.alloc(inputSamples * 2 * BYTES_PER_SAMPLE);
for (let i = 0; i < inputSamples; i++) {
const sample = input.readInt16LE(i * BYTES_PER_SAMPLE);
// 표본을 두 번 써서 샘플레이트를 두 배로 만든다 (zero-order hold)
output.writeInt16LE(sample, (i * 2) * BYTES_PER_SAMPLE);
output.writeInt16LE(sample, (i * 2 + 1) * BYTES_PER_SAMPLE);
}
return output;
}
돌아오는 24kHz 음성은 청크가 연속해서 들어온다. 청크마다 따로 변환하면 경계에서 필터 이력과 남은 표본이 끊기므로, 2월 17일에 필터 이력·간격 위상·남은 표본을 청크 사이에 이어받는 스트리밍 리샘플러로 바꿨다. 세션마다 하나씩 쓴다.
20ms 큐: 한꺼번에 보내면 안 된다
Gemini는 음성을 큰 덩어리로 빨리 돌려준다. 이걸 AudioSocket에 그대로 쏟아 내면 재생 속도가 통화 시간축과 어긋난다. 그래서 응답 음성을 20ms 단위로 잘라 큐에 넣고 타이머로 하나씩 보냈다. 8kHz면 320바이트, 16kHz면 640바이트가 20ms다.
2월 8일 커밋에서 세션마다 audioQueues와 audioTimers 맵을 두었다. 통화가 끝나면 타이머를 멈추고 큐를 지운다. 이걸 빼먹으면 끊긴 통화의 타이머가 계속 돌아 소켓에 쓰려고 한다.
통화 끝내기가 의외로 어렵다
AI가 "종료합니다" 같은 끝인사를 하면 릴레이가 HANGUP 프레임을 보내야 한다. 그런데 끝인사 텍스트가 나온 시점은 음성이 다 재생된 시점이 아니다. 바로 끊으면 끝인사가 잘린다.
2월 8일에 끝인사를 감지하면 turnComplete 이벤트까지 기다렸다가, 오디오 큐가 비면 0.5초를 더 기다린 뒤 HANGUP을 보내도록 했다. 큐가 안 비는 경우를 대비해 최대 5초 타임아웃도 뒀다. 같은 날 통화 이벤트를 구조화해서 파일로 남기는 call-log.service.ts도 추가했다. 4편의 녹음 대조와 5편의 통화 집계 모두 이 로그 위에서 나왔다.
2월 17일에는 통화 종료 요약 로그를 강화했다. 통화가 끝나면 어떤 명령이 나갔는지, 어떻게 끝났는지를 한 건의 요약으로 남기는 것이다. 같은 날 상담원 인계 쪽 수정도 하나 했다. 인계는 AMI로 통화 변수를 설정한 뒤 dialplan에 제어를 돌려주는 방식인데, 세션 정리 단계가 그 변수를 비우고 있었다. 정리가 dialplan보다 먼저 돌면 인계가 빈 값으로 넘어갈 수 있어서 정리 단계의 해당 코드를 뺐다.
프롬프트에 맡긴 판단
열흘 동안 만든 것은 "전화가 와서, AI가 말하고, 끝인사 후 끊기는" 뼈대였다. 이 시기의 구조는 모델이 말하는 내용을 텍스트로 받아 명령 태그를 뽑아내는 방식이라, 판단의 상당 부분을 프롬프트에 맡기고 있었다. 동·호수를 못 알아들었을 때 모델이 알아서 다시 묻도록 시키는 것만으로는 몇 번을 물었는지 세는 일조차 코드가 알 수 없었다.