caller_phone을 확보해 B에 전달 (mock 허용)A는 다음 6개 상태만 가진다. 빨간 점선은 바지-인 인터럽트 전이다.
구현 힌트 — gateway.py의 현재 ai_busy 플래그는 THINKING+SPEAKING을 합친 것.
바지-인을 구현하려면 SPEAKING 상태를 별도로 분리하고, send_queue에 쌓인 오디오 청크를 drop 할 수 있게 큐를 재설계해야 한다.
# 재생 중(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)
| 필드 | 타입 | 설명 | 필수 |
|---|---|---|---|
text |
string | 발화된 최종 텍스트 (STT 결과). 한 턴(turn)의 전체 내용. | 필수 |
utterance_offset |
int (ms) | 바지-인 발생 시 직전 B의 응답이 몇 ms까지 재생됐는지. 일반 턴에서는 생략/null. B는 이를 보고 "아 내가 방금 X까지 말하다 잘렸구나"를 알 수 있다. | 선택 |
caller_phone |
string | 발신자 번호. 통화 시작 시점에 확보해 세션 내내 B에 전달. Mock 데이터로 시작해도 됨. | 필수 |
turn_id |
int | 통화 내 몇 번째 턴인지 (0, 1, 2…). 로그 추적용. | 권장 |
reply_text |
string | 고객에게 재생할 자연어 응답. A는 그대로 TTS 합성. |
B는 내부적으로 DB 저장·네이버 조회 등을 하지만, A의 관심사는 아니다.
A는 reply_text만 받아서 읽으면 된다.
src/voice/)gateway.py — Twilio WebSocket 수신, 3초 고정 윈도우로 오디오 버퍼링 → STT 호출stt.py — CLOVA Speech 연동tts.py — CLOVA Voice 연동 (mulaw 8kHz 변환)greet())caller_phone 을 start 이벤트 customParameters에서 추출해 B에 전달WINDOW_CHUNKS=150 (3초) 고정. RMS 기반 무음 감지로 교체ai_busy 를 THINKING / SPEAKING 으로 분리하고, SPEAKING 중 수신 미디어를 무시하지 말고 VAD에 태워야 함utterance_offset 계산·전달 — 지금은 0ms 고정이라 B가 흐름 복구 불가clear event 전송 — send_queue만 비우면 이미 Twilio 쪽에서 버퍼링된 오디오가 계속 재생됨stop 이벤트에서 transcript + audio 덤프 구현state 필드로import google.genai, naver, db 같은 import가 없는가? (있다면 경계 침범)reply_text)utterance_offset 이 B로 전달되는가?caller_phone 이 매 턴 B에 전달되는가?