결과: 병합 (2026-10-27 18:04, google-gemini/cookbook#1433). 사실은 GitHub 의 PR·리뷰·줄 댓글·댓글·타임라인·검사 기록, Cookbook 소스(PR 의 바탕 커밋
d9df60de), Live API 레퍼런스, 기여 원장의 초안과 답글 초안, TT Lab 저장소의 음성 중계 코드에서 가져왔습니다. 시각은 한국 시간입니다.
목차
- 한 줄 요약
- 1. 무엇이 문제였나: 예제와 레퍼런스가 갈라진 자리
- 2. 원인: 예제의 어느 줄인가
- 3. 무엇을 바꿨나: 커밋 둘
- 4. 반응: 「보기보다 큰 수정」
- 5. 배운 것: 오픈소스 기여 요령
- 링크
한 줄 요약
| PR | google-gemini/cookbook#1433 websockets Live API starter: use realtime_input.audio/video and flush playback only on interrupted |
| 바꾼 것 | quickstarts/websockets/Get_started_LiveAPI.py 한 파일. 입력 필드, 소리 MIME 형식, 받는 쪽 반복문, 디코드. 커밋 둘, 29줄 추가, 34줄 삭제 |
| 반응 | 리뷰 봇의 제안 하나, 리뷰어의 「변경 요청」(직접 재현과 함께 한 줄 더), 다른 리뷰어의 오프라인 점검, 끝으로 실제 음성 대화 시험 뒤 승인 |
| 결과 | PR 공개 10-17 17:22 → 변경 요청 10-19 19:57 → 반영 22:03 → 승인 10-22 19:19 → 병합 10-27 18:04, 약 10일 41분 |
1. 무엇이 문제였나: 예제와 레퍼런스가 갈라진 자리
TT Lab 의 음성 대화 기능은 서버가 브라우저와 Gemini Live API 사이를 웹소켓으로 잇는 중계입니다(TT Lab 저장소 backend/app/lang_call.py). SDK 를 쓰지 않고
웹소켓 프로토콜을 직접 다루며, 마이크 소리는 realtimeInput.audio 에 audio/pcm;rate=16000 으로 보내고, 서버가 serverContent.interrupted 를 보내면 브라우저에
쌓인 재생을 비우고, turnComplete 에는 비우지 않습니다. 그 코드의 주석에는 2026-10-09 에 질문 넷을 한 번씩 보내 네 언어로 바르게 답했고, 말이 끝난 뒤 첫 소리가
0.88–1.48초였다는 기록이 있습니다. 한 번씩 잰 값이라 빠르기 비교는 미실측이라는 단서도 함께 적혀 있습니다.
우리가 잰 것과 안 잰 것을 나눕니다. 우리 중계가 현행 필드로 잘 돈다는 것은 위의 기록대로 쟀습니다. 그러나 이 예제를 그대로 돌려 media_chunks 가 정말로
무시되는지는 재지 않았습니다(미실측). 마이크·카메라·API 키가 필요해서, PR 본문에는 「파이썬 compile() 로 문법만 확인했고 끝까지 돌리지는 못했다」고 적었습니다.
그래서 처음 PR 은 「사용 중단된 필드를 현행 필드로 정리한다」는 정도의 무게였습니다. 이 문제가 얼마나 큰지는 4절에서 리뷰어가 알려 주었습니다. 무엇이 어긋났는지는 2절에 줄 단위로 적습니다.
2. 원인: 예제의 어느 줄인가
Gemini Cookbook 에는 같은 일을 하는 예제가 두 벌 있습니다. SDK 를 쓰는 quickstarts/Get_started_LiveAPI.py 와, 웹소켓을 직접 쓰는
quickstarts/websockets/Get_started_LiveAPI.py 입니다. 우리 중계와 같은 방식인 둘째를 읽다가 네 군데가 레퍼런스와 다른 것을 봤습니다. 줄 번호는 PR 의 바탕 커밋
d9df60de 기준입니다.
| 줄 | 예제가 하던 일 | 기준 |
|---|---|---|
| 151 | 카메라 화면을 {"media_chunks": [frame]} 으로 |
Live API 레퍼런스는 mediaChunks 를 사용 중단으로 표시하고 audio·video·text 중 하나를 쓰라고 함 |
| 181 | 화면 공유를 {"media_chunks": frame} 으로(목록이 아닌 객체 하나) |
위 151행과도 다름 |
| 204–210 | 마이크 소리를 media_chunks 에, MIME 은 "audio/pcm" |
다른 Live 예제는 audio/pcm;rate=16000 |
| 214–242 | parts[0] 만 재생하고, turnComplete 마다 재생 대기열을 비움 |
끼어들기 신호는 interrupted. SDK판 예제는 이미 「interrupted 일 때만 비우고 모든 조각을 재생」 |
받는 쪽 반복문의 주석은 「모델의 말을 끊으면 차례 끝(end of turn)을 보낸다, 그래서 대기열을 비워야 끼어들기가 된다」고 적고 있었습니다. 그런데 차례 끝은 끊지 않아도
매 답마다 옵니다. 답이 끝날 때마다 아직 재생되지 않은 소리를 버리게 되는 셈입니다. SDK판 예제의
187–199행
은 이미 interrupted 로 고쳐져 있었습니다.
정리하면, 웹소켓판 예제는 프로토콜이 바뀔 때 함께 고쳐지지 않았고, 같은 저장소의 SDK판 예제만 고쳐졌습니다. 레퍼런스가 mediaChunks 를 사용 중단으로 표시하고 있는데도
웹소켓판은 그 필드로 보냈고, 끼어들기 처리도 SDK판이 바뀐 뒤 옛 방식(turnComplete 에 비우기)에 남아 있었습니다. 두 벌의 예제가 같은 일을 할 때 한쪽만
고쳐지는 일은 흔한 것으로 보입니다. 기여 원장의 초안 메모에는 웹소켓판의 마지막 실질 변경이 2025-11 이고 그 뒤로는 모델 이름만 바뀌었다고 적혀 있습니다.
3. 무엇을 바꿨나: 커밋 둘
첫 커밋(10-17)은 2절의 네 가지를 고쳤습니다(+24/−30).
- msg = {"realtime_input": {"media_chunks": [frame]}}
+ msg = {"realtime_input": {"video": frame}}
...
- "media_chunks": [ { "data": ..., "mime_type": "audio/pcm" } ]
+ "audio": {
+ "data": base64.b64encode(data).decode(),
+ "mime_type": f"audio/pcm;rate={SEND_SAMPLE_RATE}",
+ }
...
+ # If you interrupt the model, the server sets `interrupted`.
+ if server_content.get("interrupted"):
+ while not self.audio_in_queue.empty():
+ self.audio_in_queue.get_nowait()
+
+ # A single message can carry several parts, so play all of them.
+ for part in server_content.get("modelTurn", {}).get("parts", []):
+ if b64data := part.get("inlineData", {}).get("data"):
+ self.audio_in_queue.put_nowait(base64.b64decode(b64data))
+
+ if server_content.get("turnComplete"):
+ print("\nEnd of turn")
파일이 CRLF 줄 끝을 쓰고 있어서 패치도 CRLF 를 지켰습니다. 하나는 일부러 넣지 않았습니다. 이 예제는 API 키를 웹소켓 주소의 ?key= 로 보내는데, SDK 는 같은
웹소켓에서 키를 x-goog-api-key 헤더로 보냅니다. 주소는 로그와 예외 메시지에 남기 쉽습니다. 다만 웹소켓을 직접 쓰는 쪽에서 헤더 인증이 되는지 우리가 재 보지
않았고 레퍼런스에도 적혀 있지 않아서, PR 에는 넣지 않고 본문 끝에 「따로 이야기할 거리」로만 적었습니다. 이 제안으로 이슈를 연 기록은 원장에 없습니다.
둘째 커밋(10-19)은 리뷰를 반영했습니다(4절). 웹소켓 프레임을 raw_response.decode("ascii") 로 풀던 두 곳(설정 응답과 받는 반복문)을 json.loads(raw_response) 로
바꿨습니다. json.loads 는 바이트를 받아 UTF-8 로 풉니다. 봇이 제안한 대로 .get("modelTurn", {}) 를 (server_content.get("modelTurn") or {}) 로 바꿔, 값이 명시적 null 일 때도 빈 사전으로 다루게 했습니다(inlineData 도 같음). 마이크 스트림이
열리지 않았을 때 close() 에서 죽지 않도록 if self.audio_stream: 도 더했습니다. 최종 diff 는 29줄 추가, 34줄 삭제입니다.
AI 사용은 PR 본문 끝의 「AI 도우미(Claude)의 도움으로 준비했고 내가 검토·확인했다」 한 문장으로 밝혔습니다. 기여 원장의 초안 메모에는 이 저장소의 기여 안내에
AI 정책 문구가 없다고 적혀 있습니다. 첫 커밋에는 AI 꼬리말 없이 DCO 서명과 SSH 서명만 있고, 둘째 커밋에는 Assisted-by: Claude 와 DCO 서명이 있지만 SSH
서명은 없습니다. 이 저장소는 구글 CLA 를 요구하고, 그 검사는 통과했습니다.
4. 반응: 「보기보다 큰 수정」
| 시각 | 일 |
|---|---|
| 10-17 17:22 | PR 공개. 라벨 status:awaiting review·component:quickstarts |
| 10-17 17:22–17:24 | 리뷰 봇(Gemini Code Assist)이 요약과 줄 제안 하나: modelTurn·inlineData 가 명시적 null 이면 .get(…, {}) 가 None 을 돌려주니 (… or {}) 를 쓰라 |
| 10-19 19:57 | 리뷰어가 「변경 요청」. 직접 재현 결과와 함께 한 줄을 더 고쳐 달라고 함 |
| 10-19 22:03 | 둘째 커밋, 22:13 답글 |
| 10-20 01:01 | 가짜 웹소켓으로 돌려 본 결과를 덧붙인 답글 |
| 10-20 06:01 | 다른 리뷰어가 「막는 문제 없음」 리뷰. 오프라인 점검 여덟 가지 |
| 10-22 16:51 | 첫 리뷰어가 실제 음성 대화로 끝까지 시험하고 「LGTM」 |
| 10-22 16:57 | 「변경 요청」 상태를 바꿔 달라고 부탁 |
| 10-22 18:29, 19:19 | 리뷰어가 메인테이너에게 리뷰를 요청하고, 승인으로 바꿈 |
| 10-27 18:04 | 그 메인테이너가 squash 병합 |
변경 요청은 고마운 변경 요청이었습니다. 리뷰어는 「보기보다 큰 수정」이라며, gemini-3.8-live 모델에 웹소켓으로 직접 같은 음성 클립을 보내 봤다고 했습니다.
media_chunks 로는 (rate= 가 있든 없든) 아무 반응이 없었고 realtime_input.audio 로는 바른 답이 왔다는 것입니다. 곧 main 의 예제에서는 마이크가 사실상
죽어 있었습니다. realtime_input.video 와 interrupted 처리도 확인했다고 했습니다. 그리고 한 줄을 더 요청했습니다. 서버는 기본으로 답의 글자(outputTranscription)를
함께 보내는데, 프레임을 ASCII 로 풀면 영어가 아닌 답에서 죽는다는 것입니다. main 에서 「Say hello in Japanese.」 라고 치면 재현된다고 했습니다.
같은 날 둘째 커밋으로 고치고, 설정 응답에도 같은 decode("ascii") 가 있어서 함께 고쳤다고 답했습니다. 이때는 일본어 글자가 든 프레임 하나가 이제 풀린다는 것만
로컬에서 확인했고, 실제 서버에는 다시 돌리지 않았다고 적었습니다. 몇 시간 뒤에는 받는 반복문 자체를 가짜 웹소켓(네트워크·소리 없음, 파이썬 3.12)에 물려
돌린 결과를 덧붙였습니다. 일본어 글자 프레임, modelTurn: null 프레임, inlineData: null 조각, turnComplete 넷을 넣었을 때, 고치기 전에는 첫 프레임에서
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe3 로 멈추고, 고친 뒤에는 넷을 모두 처리해 소리 조각이 재생 대기열에 닿았습니다. 여기서도
「실제 서버에는 돌리지 않았다」고 다시 적었습니다. 이 두 답글은 원장에 초안으로 남아 있습니다.
다른 리뷰어는 실제 장치 없이 할 수 있는 점검을 여덟 가지 돌려 「막는 문제가 없다」고 했습니다. 예제의 클래스를 ast 로 꺼내 가짜 카메라·화면·마이크를 물리고,
여러 조각, 바이너리·텍스트 프레임, turnComplete 뒤에도 남는 재생, interrupted 에 비워지는 재생, 유니코드를 확인했다고 했습니다. 같은 점검을 main 에 돌리면
옛 문제가 재현된다고도 했습니다. 실제 마이크·스피커·서버로는 돌리지 않았다는 단서도 붙였습니다.
마지막 확인은 첫 리뷰어가 해 주었습니다. 둘째 커밋으로 끝까지 시험했고, 5분 동안 말로 대화가 되었으며(main 에서는 모델이 마이크를 전혀 듣지 못함), 화면을 보내면
알아보고, 첫 메시지로 일본어 인사를 시켜도 재생과 「End of turn」 출력까지 된다고 했습니다. 설정 응답의 디코드를 함께 찾은 것도 짚어 주었습니다. 이 리뷰어의 리뷰
상태가 아직 「변경 요청」이라 병합이 막혀 있어서, 감사 인사와 함께 상태를 바꿔 달라고 한 번 부탁했고, 2시간 22분 뒤 승인으로 바뀌었습니다. 병합은 닷새 뒤
메인테이너가 했습니다. 병합 커밋 db6fb41f 은 두 커밋을 합친 squash 이고
저자는 우리 계정입니다.
5. 배운 것: 오픈소스 기여 요령
- 「두 벌의 예제는 서로를 기준으로 삼습니다.」 같은 저장소의 SDK판 예제가 이미 바른 처리를 하고 있었기에, 「레퍼런스에 맞추자」보다 「옆 예제와 같게 하자」가 더 짧은 근거가 되었습니다.
- 「못 잰 것은 못 쟀다고 쓰면, 리뷰어가 재 줍니다.」 우리는 이 예제를 끝까지 돌리지 못했고 그렇게 적었습니다. 리뷰어는 직접 재고 나서 「main 에서는 마이크가 죽어 있다」는, 우리가 주장하지 않은 더 큰 사실을 찾아 주었습니다. 반대로 「돌려 봤다」고 부풀렸다면 이 대화는 없었을 것으로 보입니다.
- 「봇의 작은 제안은 바로 받습니다.」
(… or {})는 사소해 보이지만, 리뷰어도 「선택이지만 봇의 지적도」라며 같은 것을 짚었습니다. 맞는 지적은 따지지 않고 넣는 편이 리뷰를 짧게 합니다. - 「「변경 요청」 상태는 저절로 풀리지 않습니다.」 리뷰어가 댓글로 LGTM 을 해도 리뷰 상태가 그대로면 병합이 막힙니다. 감사와 함께 상태를 바꿔 달라고 정중히 한 번 부탁하는 것이 그다음 일입니다.
- 「커밋마다 고지와 서명을 같이 확인합니다.」 첫 커밋에는 AI 꼬리말이, 둘째 커밋에는 SSH 서명이 빠졌습니다. 본문 고지가 있어 문제가 되지는 않았지만, 원칙과 다릅니다.
티티 한마디. 무전기 사용법 책에 「옛날 단추로 말하세요」라고 쓰여 있었어요. 그 단추는 이제 아무 데도 연결돼 있지 않은데 말이죠. 게다가 상대가 말을 마칠 때마다 아직 듣지 않은 말을 지워 버렸어요. 새 단추로 바꾼 쪽지를 드렸더니, 선생님이 직접 무전기를 들고 시험해 보시고는 「이건 생각보다 큰일이었네, 옛날 단추로는 아예 안 들려!」 하셨어요. 그리고 일본어로 대답하면 무전기가 멈춘다는 것도 찾아 주셨죠. 그것까지 고쳤더니 5분 동안 수다를 떨고 「좋아요」를 주셨어요.
링크
모든 자료는 2026-10-29 에 열람했습니다.
- PR: google-gemini/cookbook#1433 (병합 커밋
db6fb41f) - 고친 예제(바탕 커밋):
quickstarts/websockets/Get_started_LiveAPI.py - SDK판 예제(바탕 커밋):
quickstarts/Get_started_LiveAPI.py187–199행 - Live API 레퍼런스: https://ai.google.dev/api/live#BidiGenerateContentRealtimeInput