ROLE · A

인바운드 상담사 — 개발 가이드

귀와 입. 음성 I/O 전담 · 의미 해석 금지 · 바지-인 지원 · 통화 기록 저장
A는 의미를 모른다. 들어오는 소리를 텍스트로 바꿔 B에게 넘기고, B가 돌려준 텍스트를 음성으로 재생한다. 그뿐이다.

1A는 무엇을 하고, 무엇을 하지 않나

✓ 해야 할 일

  • Twilio/VoIP 음성 스트림 수신 · 송출
  • STT로 발화 텍스트화 (발화 종료 감지 = VAD)
  • B가 돌려준 텍스트를 TTS로 재생
  • 재생 중 상대 목소리 감지 → 즉시 중단 (바지-인)
  • 중단된 재생 위치(몇 글자/몇 초까지 나갔는지)를 B에게 함께 전달
  • 통화 종료 시 전체 텍스트 + 음성 녹음 저장
  • 수신 시점에 caller_phone을 확보해 B에 전달 (mock 허용)

✗ 하면 안 되는 일

  • Intent 분류, 슬롯 추출 등 의미 해석
  • 네이버/DB 등 데이터 조회
  • 예약/취소 등 외부 시스템 호출
  • "예약되었습니다" 같은 확정 문구를 직접 만들어내기
  • B를 거치지 않은 응답 생성

2A의 내부 상태 머신

A는 다음 6개 상태만 가진다. 빨간 점선은 바지-인 인터럽트 전이다.

IDLE 통화 대기 GREETING 첫 인사 TTS LISTENING 음성 수신 + VAD THINKING B 응답 대기 SPEAKING TTS 재생 중 INTERRUPTED 재생 중단 · 위치 캡처 END 기록 저장 전화 수신 완료 발화 종료 B 텍스트 수신 재생 끝 🎤 음성 감지 즉시 중단 + offset 저장 고객 끊음

구현 힌트gateway.py의 현재 ai_busy 플래그는 THINKING+SPEAKING을 합친 것. 바지-인을 구현하려면 SPEAKING 상태를 별도로 분리하고, send_queue에 쌓인 오디오 청크를 drop 할 수 있게 큐를 재설계해야 한다.

3바지-인 (barge-in) 처리 — A의 가장 어려운 요구사항

🎤 재생 중 상대 목소리가 들리면

다음 5단계를 100ms 이내에 마쳐야 자연스럽다.

1
음성 감지
수신 스트림 VAD가 RMS 임계 초과 감지
2
재생 중단
send_queue 비우기
Twilio clear event 송신
3
위치 캡처
전송한 오디오 ms 수로
utterance_offset 계산
4
경청
들어온 음성 누적 → VAD가 발화 종료 신호
5
B에 전달
text + offset + phone
B가 대화 흐름 복구

핵심 의사 코드

# 재생 중(SPEAKING)에도 수신 미디어를 읽는다 — ai_busy 아님에 주의
async def on_media_chunk(mulaw: bytes):
    if state == "SPEAKING":
        rms = audioop.rms(audioop.ulaw2lin(mulaw, 2), 2)
        if rms > BARGE_IN_THRESHOLD:        # ex) 500
            barge_counter += 1
            if barge_counter >= 3:           # ~60ms 연속 감지
                await abort_tts()             # 1) send_queue.clear() + Twilio clear
                offset_ms = compute_offset()    # 2) 지금까지 송신한 ms
                start_listening(prev_offset=offset_ms)
                state = "LISTENING"
    elif state == "LISTENING":
        buffer.extend(mulaw)
        if vad_detected_end(buffer):      # ≥0.8s 무음
            utter = bytes(buffer); buffer.clear()
            text = await stt(utter)
            reply = await B.chat(text,
                                  utterance_offset=prev_offset,
                                  caller_phone=caller_phone)
            await speak(reply)

4B와의 인터페이스 계약

A → B 로 보내는 것

필드타입설명필수
text string 발화된 최종 텍스트 (STT 결과). 한 턴(turn)의 전체 내용. 필수
utterance_offset int (ms) 바지-인 발생 시 직전 B의 응답이 몇 ms까지 재생됐는지. 일반 턴에서는 생략/null. B는 이를 보고 "아 내가 방금 X까지 말하다 잘렸구나"를 알 수 있다. 선택
caller_phone string 발신자 번호. 통화 시작 시점에 확보해 세션 내내 B에 전달. Mock 데이터로 시작해도 됨. 필수
turn_id int 통화 내 몇 번째 턴인지 (0, 1, 2…). 로그 추적용. 권장

B → A 로 받는 것

reply_text string 고객에게 재생할 자연어 응답. A는 그대로 TTS 합성.

B는 내부적으로 DB 저장·네이버 조회 등을 하지만, A의 관심사는 아니다. A는 reply_text만 받아서 읽으면 된다.

5시나리오별 시퀀스

① 통화 시작 → 첫 턴 (정상 흐름)
고객
Twilio
A · gateway.py
B · llm.py
DB
전화 수신
WS start · From=010…
caller_phone 저장
greet(caller_phone)
LLM 인사 생성
"안녕하세요 살롱드달희입니다"
TTS audio (mulaw 8kHz)
합성 음성
─── 고객 첫 발화 ───
"내일 4시 커트 예약돼?"
media 청크
VAD: 발화 종료 감지
STT → 텍스트
chat(text, caller_phone)
check_naver_availability
반환: [14:00, 16:00]
"14시, 16시 가능합니다"
TTS audio
합성 음성
바지-인 — 재생 중 고객이 말을 끊음
고객
Twilio
A · gateway
B · llm
state = SPEAKING
TTS audio 스트리밍 "내일은 14, 16, 18시 가능…"
합성 음성 (일부 전달)
재생 위치 추적: 3200ms 송신됨
🎤 바지-인 트리거 — 고객이 끼어들기
"아 16시로!"
media 청크 수신
RMS 임계 초과 · 3회 연속 감지
Twilio clear event (재생 중단)
send_queue 비우기 · offset_ms=3200 캡처
state = LISTENING
─── 인터럽트 후 경청 ───
(고객 계속 발화)
media
VAD 종료 · STT
chat(text="아 16시로", utterance_offset=3200, phone)
B: "16시 제안 중 잘렸구나" → 예약 흐름 진입
"16시로 도와드릴까요?"
TTS audio
③ 통화 종료 → 기록 저장
고객
Twilio
A · gateway
파일시스템
전화 끊음 (hang up)
WS stop 이벤트
대화 전문 플러시
write transcript → calls/{sid}.txt
write audio → calls/{sid}.wav
state = IDLE

6A 주변 데이터 흐름 (한 장 요약)

고객 전화 발신 Twilio Media Streams A — 인바운드 • WS 수신/송신 • VAD · STT · TTS • 바지-인 처리 • 통화 기록 의미 해석 X · DB 접근 X B — 뇌 LLM · 조회 통화 기록 txt + wav 음성 WS text+offset+phone reply_text 통화 종료 시 저장

7구현 현황 — 무엇이 있고, 무엇을 더 만들어야 하나

✓ 이미 구현됨 (src/voice/)

  • gateway.py — Twilio WebSocket 수신, 3초 고정 윈도우로 오디오 버퍼링 → STT 호출
  • stt.py — CLOVA Speech 연동
  • tts.py — CLOVA Voice 연동 (mulaw 8kHz 변환)
  • 인사 TTS 자동 재생 (greet())
  • caller_phone 을 start 이벤트 customParameters에서 추출해 B에 전달
  • WebSocket으로 프론트에 transcript/status 브로드캐스트

⚠ A 개발자가 이어서 해야 할 것

  • 진짜 VAD — 지금은 WINDOW_CHUNKS=150 (3초) 고정. RMS 기반 무음 감지로 교체
  • 바지-인ai_busyTHINKING / SPEAKING 으로 분리하고, SPEAKING 중 수신 미디어를 무시하지 말고 VAD에 태워야
  • utterance_offset 계산·전달 — 지금은 0ms 고정이라 B가 흐름 복구 불가
  • Twilio clear event 전송send_queue만 비우면 이미 Twilio 쪽에서 버퍼링된 오디오가 계속 재생됨
  • 통화 종료 시 기록 저장stop 이벤트에서 transcript + audio 덤프 구현
  • 상태 머신 정리 — 현재 암묵적 불린 플래그들을 명시적 state 필드로

8PR 전 셀프 체크리스트