로봇 헤드 음성 서버
작은 로봇 헤드들이 보드 제조사의 클라우드 대신 직접 접속해서 대화하는 자체 음성 서버
- 기간
- 2026년 9월 – 지금
- 대상
- 컴패니언 로봇 헤드를 만드는 작은 회사
- 역할
- 1인: 프로토콜, 오디오, 배포, 운영
- 기술
- TypeScript, Node.js, ws, Opus, Gemini Live, OpenAI Realtime, Groq, edge-tts, Prisma, SQLite, Docker, Caddy
- 규모
- 커밋 317개, TypeScript 2.9만 줄, 테스트 1.9만 줄, 스크립트 1.7만 줄
- 외주 작업, neoworks
다른 언어: english
어떤 프로젝트인가요
한 작은 회사가 ESP32 보드로 로봇 헤드를 만들어요. 얼굴을 움직이는 서보, 스피커, 마이크가 들어 있어요. 펌웨어는 오픈소스 웹소켓 프로토콜을 쓰고, 기본 상태에서는 모든 대화를 보드 제조사의 클라우드로 보내요. 이 서버가 그 클라우드를 대신해요. 헤드가 여기에 직접 접속해서 opus 오디오를 주고받아요. 움직임은 제조사 펌웨어가 그대로 맡고, 대화는 제가 맡아요.
헤드마다 관리자 페이지에서 엔진을 정해요. Gemini Live, OpenAI Realtime, 무료 파이프라인(음성 인식은 Groq Whisper, 답변은 Groq의 오픈 모델, 목소리는 edge-tts), 아니면 제조사 클라우드로 되돌려 보내기 중 하나예요. 첫 커밋부터 멀티테넌트로 만들었어요.
어려웠던 부분
대부분은 펌웨어가 실제로 어떻게 동작하는지 알아내는 일이었어요. 펌웨어 소스와 실제 세션 녹음에서요.
타이밍이 틀린 오디오는 그냥 사라져요. 펌웨어는 "speaking" 상태일 때만 들어온 오디오를 재생해요. 다른 상태에서는 프레임을 소켓에서 읽고 그냥 버려요. 양쪽 어디에도 에러가 안 남아요. 제조사 문서에는 이 얘기가 없고, 펌웨어 소스에 있어요. 서버가 그 상태를 바꿀 수 있는 수단은 tts start 메시지 하나뿐이라서, 모든 답변은 정해진 순서를 따라요. start, 프레임, 다 비우기, stop이에요.
헤드가 자기 말에 대답하고 있었고, 제 첫 진단은 틀렸어요. 첫 실제 보드에서 답변이 자꾸 중간에 끊겼어요. 녹음을 보니 마이크에 모터 웅웅거림이 크게 들어 있어서, 그게 원인이라고 판단했어요. 그런데 고객은 헤드가 자기 목소리를 듣고 있다고 했고, 그 말이 맞았어요. 세션 기록 89개가 마이크 모드에 따라 깔끔하게 갈렸어요. 재생 중에도 마이크가 열려 있는 모드에서는 882초 동안 말하면서 스스로 끊긴 게 75번, 펌웨어가 마이크를 닫는 모드에서는 168초 동안 0번이었어요. 웅웅거림은 녹음 하나에서만 나온 거였고, 그 소음 바닥은 중앙값의 17배였어요. 헤드가 말하는 동안에는 마이크를 모델로 넘기지 않게 고쳤어요.
그 수정이 헤드 말에 끼어든 사람의 말까지 삼켰어요. 다음 날 피드백은 이랬어요. 말을 끊으면 헤드가 멈추긴 하는데, 그때 한 말에는 대답을 안 한다는 거예요. 게이트가 겹친 부분을 버리고 있었어요. 지금은 마지막 1.6초를 들고 있다가, 끼어들기가 확인되면 그걸 내보내요. 그래서 모델이 문장 전체를 들어요. 보드에서는 보드가 보내는 abort 메시지가 확인 신호예요. 휴대폰 테스트 페이지에서는 감지기가 우리가 보낸 오디오를 기준으로 세션마다 스피커에서 마이크로 들어오는 에코를 측정해요. 처음엔 엄격하게 시작해서 근거가 쌓일 때만 느슨해져요. 그래서 실수하더라도 끼어들기를 놓치는 쪽이지, 헤드가 스스로를 끊는 쪽은 아니에요.
답이 오기 전에 보드가 연결을 끊었어요. 한 세션에서 질문이 모델에 도착하고 2,283ms 뒤에 보드가 소켓을 닫았어요. 같은 바이트를 다시 재생해 보니 답변은 2,883~3,607ms 걸렸어요. 펌웨어가 스스로 연결을 끊는 경로는 전부 listening 상태에서만 돌아요. 그래서 지금은 사용자가 말을 멈추자마자 서버가 헤드를 speaking 상태로 넣고, 답이 나올 때까지 거기에 붙잡아 둬요.
새 펌웨어는 전이중(full duplex)이 되면서 문제가 뒤집혔어요. 나중 펌웨어는 기기에서 에코를 제거해요(마이크 에코 99번째 백분위수가 272, 예전 보드는 2,461). 그런 헤드에서는 게이트를 없앴더니, 이번엔 기침, TV, 방 건너편 목소리가 답변을 끊었어요. 처음 규칙은 7프레임 창 안에 큰 소리 5프레임이었는데, 잡음 두 번에 작은 목소리가 더해지면 넘어가 버렸어요. 지금은 목소리 프레임이 처음 잡히면 헤드가 잠깐 멈추고(되돌릴 수 있어요), 끊김 없이 4프레임(240ms)이 이어져야 실제로 끊어요. 말이 끝났다고 보는 기준도 더 기다려야 했어요. 한 사용자의 짧은 쉼 중 12번이 350~700ms 사이였고, 그래서 모델이 문장 절반에 대답했어요. 대기 시간을 350ms에서 550으로, 다시 엔진에 따라 700이나 750으로 늘렸어요.
그 밖에 들어 있는 것들
- 목소리 선택: 30개 목소리를 AI 청취 패널로 순위 매겨 봤는데, 4번 돌려도 같은 순위가 다시 안 나왔어요. 그래서 후보는 측정한 음높이 범위로 골랐어요.
- 무료 엔진에는 서버 쪽 끝말잇기 심판이 있어요. 잘못 들은 단어는 자모 2개 이내로 고칠 수 있을 때만 맞는 단어로 바꿔요. 3개까지 허용했더니 제대로 들은 단어를 다른 단어로 바꿔 버린 적이 있어요.
- 무선(OTA) 펌웨어 업데이트를 기기마다 서버의 프로비저닝 엔드포인트로 밀어 줘요. 헤드가 어느 서버로 접속할지도 이 엔드포인트에서 배워요.
- 지터 버퍼: 두 엔진 모두에서 말이 뚝뚝 끊겨서 넣었어요. 재생 전에 5프레임(300ms)을 기다리고, 모델이 멈칫하면 10프레임까지 늘어나요.
- 헤드와 같은 프로토콜로 말하는 휴대폰 테스트 페이지, 지식 베이스, 엔진끼리 공유하는 기억, 영상 촬영용 리모컨이 있어요.
- Caddy 뒤에 Docker 컨테이너 하나, Prisma로 SQLite를 써요. 배포는 대화 중인 사람이 없을 때까지 기다려요.
지금은
운영 데이터베이스에 세션 968개, 대화 21시간, 턴 3,179개가 쌓였어요. 그중 528개 세션이 고객의 실제 헤드 4대에서 나왔어요. 10월 말부터는 고객의 자체 모델 키로 돌아가요. 마지막 커밋은 10월 29일이고, 작업은 계속되고 있어요.