본문으로 건너뛰기
5분 AI.
GITHUB TRENDING C++ WEEKLY #2 딥다이브 · 2026-10-23 분석

xiaozhi-esp32 딥다이브
— 작은 보드에 목소리와 도구를 잇는 법

펌웨어

빠른 판단

무엇
ESP-IDF 기반으로 오디오 스트리밍·화면·네트워크·MCP 기기 제어를 묶은 ESP32 음성 단말 펌웨어
이럴 때
마이크·스피커가 있는 AI 기기를 만들고 서버의 음성 응답·도구 호출을 볼륨 등 실제 장치 동작에 연결할 때
입력
마이크 오디오와 서버의 응답·MCP 도구 요청
결과
스피커·화면 출력과 C++ 기기 제어 동작
마이크에 한 말을 AI가 알아듣고, 스피커로 답하고, 조명까지 켜게 하려면 무엇이 필요할까요? xiaozhi-esp32는 그 연결을 구현한 ESP32용 C++ 펌웨어입니다. 음성 압축, 실시간 태스크, 상태 머신, 하드웨어 추상화, MCP 도구 호출을 실제 코드로 공부합니다. C++ 구현 86,089줄과 헤더 29,541줄을 갖춘 저장소에서, 처음 읽을 핵심 경로를 골라 따라갑니다. (78/xiaozhi-esp32 · 스타 31,804 · 포크 7,436 · MIT · 2026-10-23 GitHub 기준 · 코드 분석은 커밋 b3ff05c 기준)
목차
  1. 프로젝트 한줄 요약
  2. 왜 주목받는가
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석
  5. 디렉토리 구조 해부
  6. 학습 포인트 (기술별)
  7. 하드웨어 / 시스템 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크

1프로젝트 한줄 요약

대화하는 기기를 만드는 펌웨어, 그리고 서버와의 경계

xiaozhi-esp32는 마이크·스피커·화면·네트워크를 묶어 AI 음성 단말로 만드는 ESP-IDF 기반 펌웨어입니다. 여기에 MCP 서버를 넣어, AI가 장치의 기능을 발견하고 호출할 수 있게 합니다. 예를 들어 “소리를 줄여 줘”라는 요청은 최종적으로 볼륨을 설정하는 C++ 함수 호출로 이어집니다.

이 저장소에서 가장 많이 배울 것은 AI 모델 자체보다, AI 응답을 실제 기기의 동작으로 연결하는 시스템 설계입니다. 일반적인 대화 경로에서는 서버가 음성 인식·언어 모델·음성 합성을 맡고, ESP32는 녹음·전처리·압축·전송·재생을 맡습니다. 로컬 깨우기 단어 인식과 LLM 추론은 서로 다른 일입니다.

용어
펌웨어와 마이크로컨트롤러
펌웨어는 기기의 플래시 메모리에 기록되어 하드웨어를 직접 움직이는 프로그램입니다. ESP32 같은 마이크로컨트롤러는 CPU·메모리·주변장치 제어 기능을 작은 칩에 담습니다. PC용 앱과 달리 메모리와 처리 시간이 빠듯하므로, “일을 언제 하고 얼마나 쌓아 둘 것인가”가 기능만큼 중요합니다.

1-1. 한 번의 대화에서 일어나는 일

  1. 버튼을 누르거나 설정된 깨우기 단어를 말합니다. 음성 엔진이 감지한 이벤트가 Application에 전달됩니다.
  2. 오디오 채널이 닫혀 있으면 새로 열고 hello를 교환하며, 음성 형식과 MCP 지원 여부를 알립니다. 이미 열린 채널은 재사용합니다.
  3. 마이크의 PCM을 보통 16 kHz 단일 채널로 준비하고, 60 ms 단위 Opus 패킷으로 인코딩해 보냅니다.
  4. 서버가 대화를 처리합니다. 인식 문장은 stt, 합성 음성 상태는 tts, 표정 정보는 llm 메시지로 들어옵니다.
  5. 장치는 응답 음성을 디코딩해 재생하고, 텍스트와 표정을 화면에 표시합니다. 제어 요청이 있으면 MCP 도구를 실행합니다.

이 경로는 main/application.cc의 InitializeProtocol()과 main/audio/audio_service.cc에서 확인할 수 있습니다. 서버가 보내는 llm 메시지를 처리한다는 사실이, 장치 안에 LLM 추론 엔진이 있다는 뜻은 아닙니다.

전체를 이해하는 비유

ESP32는 귀·입·손을 맡고, 대화 서버는 해석과 답변을 맡습니다

안내 창구를 떠올려 보세요. 창구 직원은 손님의 말을 듣고, 전문 상담 부서에 전달하고, 답을 읽어 주며, 필요하면 조명 스위치를 누릅니다. 여기서 기기는 창구 직원이고, 서버의 AI는 전문 상담 부서입니다.

MCP는 “어떤 스위치를 어떤 인자로 누를 수 있는가”를 적은 업무 목록입니다. 목록을 이해한 AI가 요청을 만들면, 기기는 허용된 함수와 인자에 맞는지 확인한 뒤 실행합니다.

먼저 읽을 세 파일
입구 → 조정자 → 실제 작업자 순서로 읽기

main/main.cc의 app_main() → main/application.cc의 Initialize()·Run() → main/audio/audio_service.cc의 입출력 태스크를 따라가세요. 처음부터 모든 보드 드라이버를 읽을 필요는 없습니다.

2왜 주목받는가

대화형 AI를 손에 잡히는 기기로 바꾸는 학습 가치

2-1. “대답하기”에서 “기기를 움직이기”로

2026-10-23 기준 GitHub Trending C++ Weekly 2위, 스타 31,804개, 포크 7,436개입니다. 이 순위와 누적 관심은 확인할 수 있지만, 특정 영상이나 업데이트가 순위 상승의 원인이라고 단정할 근거는 없습니다. 여기서는 소스에서 관찰되는 매력으로 주목받는 배경을 해석합니다.

첫째는 MCP와 하드웨어의 연결입니다. McpServer::AddCommonTools()는 상태 조회와 볼륨 조절을 등록하고, 화면 밝기·카메라 도구는 해당 장치가 있을 때 추가합니다. 둘째는 확장 가능한 보드 구조입니다. 같은 대화 로직을 다양한 디스플레이·코덱·통신 장치와 결합하는 사례가 풍부합니다.

화려한 대화 시연 뒤에서, 서로 다른 하드웨어와 느린 네트워크를 견디는 공통 코드를 읽을 수 있다는 점이 이 저장소의 강점입니다. 특히 오디오 큐의 지연 제한과 콜백 실행 위치는 작은 예제에서 보기 어려운 실전 주제입니다.

2-2. 비슷한 선택지와 비교하면

선택지중심 역할학습할 때의 차이
xiaozhi-esp32AI 대화용 기기 펌웨어와 장치 측 MCPC++ 소스에서 음성 태스크·전송·보드 이식·도구 호출을 함께 추적할 수 있습니다.
ESPHome Voice Assistant기기 음성을 Home Assistant의 Assist로 전달Home Assistant 연동과 설정 중심 접근을 익히기 좋습니다. xiaozhi는 자체 대화 프로토콜과 펌웨어 내부 설계를 읽는 데 초점이 있습니다.
일반 버튼·GPIO 예제입력에 따라 핀 한 개를 제어시작은 쉽지만, 음성 스트림·동시성·서버 도구 호출까지는 별도 설계가 필요합니다.
대화 서버 프로젝트ASR·LLM·TTS와 모델 제공자 연결서버 구성과 모델 통합을 배웁니다. 이 저장소는 그 서버와 통신하는 단말 쪽을 보여 줍니다.

ESPHome의 역할은 공식 Voice Assistant 문서 기준입니다. 어느 쪽이 무조건 빠르거나 음질이 좋다는 성능 비교는 아닙니다. 기존 Home Assistant 환경을 확장하려는지, 펌웨어와 대화 프로토콜을 직접 공부하려는지에 따라 선택이 달라집니다.

2-3. 기능 목록을 읽을 때 분리해야 할 세 층

층확인되는 내용해석
기기 소스Opus, 음성 엔진, 화면, 상태 머신, MCP 디스패처이 저장소에서 구현을 직접 읽을 수 있습니다.
보드별 선택 기능카메라, AEC, 배터리, 4G, Ethernet 등모든 보드가 모든 기능을 가진 것은 아닙니다. Kconfig와 보드 구현을 확인해야 합니다.
서버 연동 기능Qwen·DeepSeek 연계, 화자 인식, 클라우드 MCPREADME의 전체 서비스 기능 설명입니다. 모델 추론·화자 인식 서버 구현이 이 펌웨어에 포함된다는 의미는 아닙니다.

README는 공식 서버 연결과 별도의 Python·Java·Go 서버 프로젝트를 소개합니다. 이 생태계는 활용 범위를 넓히지만, 서버의 배포 방법·서비스 정책·실제 호환성은 각각 확인해야 합니다.

비유

같은 리모컨 설계를 여러 TV에 맞추는 일과 비슷합니다. 버튼의 뜻은 같아도 각 TV의 신호 방식은 다릅니다. 이 프로젝트의 보드 계층은 하드웨어 차이를 흡수하고, 대화 로직은 “듣기·말하기·제어하기”라는 공통 동작에 집중하게 합니다.

3기술 스택 전체 지도

웹 서비스의 구분을 빌리되, 실제 중심은 임베디드 클라이언트입니다

3-1. 백엔드: 서버와 통신하는 펌웨어 코어

이 저장소에는 웹 API 서버의 실행 진입점이나 모델 제공자용 백엔드가 없습니다. 서버와 통신하고 기기 동작을 조정하는 코어는 Application, AudioService, Protocol, McpServer입니다. 장치 측 MCP 서버와 대화 백엔드는 역할이 다르며, 여기서 “서버”는 도구를 제공하는 쪽이라는 뜻으로도 쓰입니다.

기술선언·설정실제 쓰임
C++main/CMakeLists.txt: -std=gnu++23상태·콜백·RAII·이동 소유권·std::expected를 사용하는 펌웨어 구현
ESP-IDFmain/idf_component.yml: >=6.0.1FreeRTOS, GPIO, I2S, I2C, 네트워크, OTA, 플래시 API 제공
ESP-SRespressif/esp-sr: ==2.4.7WakeNet·MultiNet과 AFE 음성 전처리 경로
오디오 코덱espressif/esp_audio_codec: ~2.5.0Opus 인코딩·디코딩. 일반 PC용 libopus 버전을 별도로 고정한 선언은 아닙니다.
오디오 효과espressif/esp_audio_effects: ~1.3.0입출력 샘플레이트 변환 등
하드웨어 코덱espressif/esp_codec_dev: ~1.6.2보드의 실제 ADC·DAC·오디오 코덱 제어 기반
JSON·암호화cJSON, Mbed TLS/PSA Crypto 사용제어 메시지 파싱과 MQTT/UDP 음성 암복호화. 독립 버전은 이 매니페스트에 명시되지 않습니다.

위 의존성 버전은 모두 main/idf_component.yml의 선언값입니다. ==는 정확한 버전 고정이고, ~·^는 호환 범위 표현입니다. 추적 파일에 dependencies.lock이 없어, 범위 선언을 실제 설치된 정확한 버전처럼 읽으면 안 됩니다.

3-2. 프론트엔드: 브라우저 대신 화면·LED·버튼

사용자 접점은 main/display/, main/led/, 보드의 버튼 콜백입니다. React나 Vue 앱 구조가 아니며, package.json·Cargo.toml·pyproject.toml·go.mod도 추적 파일에 없습니다. Python은 주로 빌드·리소스·테스트 도구에 쓰입니다.

화면·입력 요소의존성 선언읽을 위치
그래픽 UIlvgl/lvgl: ~9.5.0
esp_lvgl_port: ~2.9.0
main/display/의 LCD·LVGL 경로
폰트78/xiaozhi-fonts: ~2.0.0CMake의 폰트 선택과 main/assets/
버튼·회전 입력espressif/button: ~4.2.1
espressif/knob: ^1.1.0
main/boards/common/button.*와 보드별 초기화
카메라espressif/esp32-camera: ^2.1.7
espressif/esp_video: ^2.4.1
대상 칩 조건이 붙은 선택 기능. 모든 빌드에 같은 경로가 포함되지는 않습니다.
네트워크 연결78/esp-wifi-connect: ~3.3.1
78/esp-ml307: ~3.8.1
Wi-Fi 설정·연결과 모뎀 계층

sdkconfig.defaults에는 # LVGL 9.2.2라는 주석이 남아 있지만, 현재 의존성 선언은 ~9.5.0입니다. 버전 판정에는 오래된 설명 주석보다 실제 매니페스트를 우선해야 합니다.

용어
Kconfig와 조건부 빌드
Kconfig는 “어느 보드인가, 어떤 언어인가, AEC를 켤 수 있는가”를 설정 항목으로 표현하는 방식입니다. 선택 결과는 CONFIG_... 값이 되어 CMake의 소스 선택과 C++의 조건부 컴파일에 영향을 줍니다. 실행 중 메뉴 선택과는 다릅니다.

3-3. 인프라·빌드: 변형이 많은 제품을 관리하는 방법

계층실제 파일·값배울 점
루트 빌드CMakeLists.txt: CMake 3.16 이상, MINIMAL_BUILD ON, PROJECT_VER "2.5.3"SDK를 불러온 뒤 필요한 컴포넌트만 포함합니다.
보드 빌드scripts/build.py: _collect_variants(), build_board()JSON 설정·칩·언어·깨우기 모델을 빌드 설정으로 연결합니다.
Dockerdocker/firmware-builder/Dockerfile: espressif/idf:release-v6.1대화 서버 이미지가 아니라 펌웨어 빌더 이미지입니다.
빌드 CI.github/workflows/build.yml: 같은 IDF 이미지, actions/checkout@v7, actions/upload-artifact@v7변경 파일에 따라 빌드 대상을 선택하고 바이너리를 보관합니다.
릴리스 CI.github/workflows/release.yml: scripts/release.py 호출릴리스 목록 준비 → 변형별 패키징 → 누락 검증·게시로 단계를 나눕니다.
호스트 검사scripts/tests/의 테스트 파일 10개장치 없이 빌드 선택·리소스·MCP 일부를 검사하는 코드가 있습니다.

.github/workflows/build.yml은 수동 실행에서 전체 목록을 선택합니다. 일반 변경에서는 _select_variants_for_changes()가 공통 코드 변경에 대표 변형을, 보드 변경에 해당 보드의 변형을 고릅니다. 모든 푸시에서 모든 보드를 빌드한다는 구조는 아닙니다.

# main/idf_component.yml의 핵심 선언
  espressif/esp-sr: ==2.4.7
  espressif/esp_audio_codec: ~2.5.0
  lvgl/lvgl: ~9.5.0
  esp_lvgl_port: ~2.9.0
  idf:
    version: '>=6.0.1'

# CMakeLists.txt의 펌웨어 개발 버전
set(PROJECT_VER "2.5.3")
버전 읽기의 원칙
릴리스·개발 버전·SDK 버전은 서로 다른 숫자입니다

최신 공개 릴리스는 v2.5.2(2026-10-11), 분석 커밋의 프로젝트 버전은 2.5.3, 최소 SDK는 6.0.1입니다. README와 빌더는 ESP-IDF의 release/v6.1 브랜치를 권장합니다. ESP32-S31은 README 기준 IDF 6.1 이상이 필요합니다.

4아키텍처 심화 분석

부팅부터 음성 패킷과 MCP 호출까지, 데이터가 이동하는 길

4-1. 시스템 전체 구조

아래에서 상자 안은 펌웨어, 상자 밖의 대화 서버는 별도 시스템입니다. 화살표는 데이터나 제어 흐름입니다. 네트워크 수신 콜백과 메인 루프가 항상 같은 태스크에서 실행되는 것은 아니라는 점을 기억하세요.

사용자 음성                          사용자 버튼
    |                                    |
    v                                    v
+---------------- ESP32 펌웨어 ------------------------+
| AudioCodec --> AudioInputTask --> AudioEngine         |
|                                     |               |
|       깨우기/VAD 이벤트 -------------+               |
|                   |                 | 16 kHz PCM    |
|                   v                 v               |
|             Application       인코딩 큐             |
|             상태 머신             |                 |
|             메인 루프         OpusCodecTask          |
|                   |                 |               |
|                   +----------- 송신 큐               |
|                   |                                 |
|                   v                                 |
|       Protocol: WebSocket 또는 MQTT + UDP            |
|                   | 음성 수신                       |
|                   v                                 |
|              디코딩 큐 --> OpusCodecTask             |
|                                  |                  |
|                              재생 큐                |
|                                  v                  |
|                       AudioOutputTask --> 스피커    |
|                                                     |
| 제어 JSON --> Application --> 화면·표정              |
|                  |                                  |
|                  +--> McpServer --> Schedule()       |
|                                       |             |
|                              메인 태스크의 도구 실행 |
|                                       v             |
|                         Board --> 볼륨·조명·카메라   |
+----------------------|------------------------------+
                       | Opus / 제어 JSON / MCP
                       v
              별도의 대화 서버
       ASR + LLM + TTS 또는 실시간 음성 모델
                       |
                 모델·외부 도구 서비스

main/main.cc의 app_main()은 먼저 NVS를 초기화하고, Application::GetInstance()를 얻어 Initialize()와 Run()을 호출합니다. Run()은 종료되지 않는 메인 이벤트 루프입니다. 부팅의 입구를 알면 수천 줄짜리 파일도 읽을 순서를 잡을 수 있습니다.

4-2. 패턴 ① 이벤트 루프 + 상태 머신

Application::Run()은 xEventGroupWaitBits()로 이벤트를 기다립니다. 네트워크 연결, 깨우기 단어, 오디오 송신 준비, 상태 변경 등이 비트로 표현됩니다. 콜백에서 할 일이 생기면 Schedule()이 작업을 큐에 넣고 MAIN_EVENT_SCHEDULE을 켭니다.

// main/application.cc: 작업 등록
void Application::Schedule(std::function<void()>&& callback) {
    {
        std::lock_guard<std::mutex> lock(mutex_);
        main_tasks_.push_back(std::move(callback));
    }
    xEventGroupSetBits(event_group_, MAIN_EVENT_SCHEDULE);
}

// Run()의 핵심: 잠금을 푼 뒤 콜백 실행
auto tasks = std::move(main_tasks_);
lock.unlock();
for (auto& task : tasks) {
    task();
}

콜백을 실행하는 동안 큐의 잠금을 붙들지 않는 것이 포인트입니다. 콜백이 다시 작업을 등록하더라도 같은 잠금을 기다리며 멈추는 일을 피합니다. 이벤트 비트는 “할 일이 있다”는 신호이고, 실제 여러 작업은 main_tasks_가 보관합니다. 같은 비트가 두 번 켜졌다고 두 개의 이벤트 객체가 쌓이는 것은 아닙니다.

상태는 main/device_state_machine.cc의 IsValidTransition()에서 제한합니다. Application::SetDeviceState()는 TransitionTo()로 위임하며, 허용되지 않은 전이는 경고 후 false를 돌려줍니다.

대표적인 대화 경로
unknown --> starting --> activating --> idle
                                        |
                                        v
                                    connecting
                                        |
                                        v
                                    listening <--> speaking
                                        |             |
                                        +----> idle <-+

설정·업데이트 경로도 별도 존재
starting --> wifi_configuring --> activating --> upgrading
idle --> notifying --> idle

이 그림은 전체 전이표의 일부입니다. 코드에서는 connecting → speaking을 바로 허용하지 않고 listening을 거치게 합니다. 같은 상태로의 요청은 성공하는 무동작입니다. current_state_의 원자적 읽기·쓰기가 모든 복합 동작을 자동으로 보호하는 것은 아니므로, 앱 변경을 메인 태스크로 모으는 규칙과 함께 이해해야 합니다.

비유

이벤트는 호출벨이고, 작업 큐는 주문서이며, 상태 머신은 주방의 작업 규칙입니다. 호출벨만으로 주문 내용을 알 수 없고, 주문서가 있다고 조리 중인 기계를 아무 상태로나 바꿀 수 있는 것도 아닙니다. 세 장치가 서로 다른 문제를 해결합니다.

4-3. 패턴 ② 하드웨어 추상화 + 팩터리

main/boards/common/board.h의 Board는 GetAudioCodec(), GetDisplay(), GetNetwork(), StartNetwork() 등의 인터페이스를 제공합니다. 공통 앱은 구체적인 보드 이름이나 핀 번호 대신 이 인터페이스를 호출합니다.

DECLARE_BOARD(CompactWifiBoard)는 create_board() 팩터리를 정의합니다. Board::GetInstance()가 이를 통해 보드 하나를 만듭니다. CMake는 선택한 BOARD_DIR의 *.cc·*.c를 포함하므로, 빌드 하나에 보드 팩터리도 하나여야 합니다.

// main/boards/common/board.h의 팩터리 매크로
#define DECLARE_BOARD(BOARD_CLASS_NAME) \
void* create_board() { \
    return new BOARD_CLASS_NAME(); \
}

// 보드 구현 끝에서 구체 클래스를 연결
DECLARE_BOARD(CompactWifiBoard);

선택 기능의 처리도 좋은 예제입니다. main/boards/common/board.cc의 기본 GetDisplay()는 NoDisplay, GetLed()는 NoLed를 반환하고 GetCamera()는 nullptr를 반환합니다. 화면·LED에는 빈 구현을, 카메라에는 존재 여부 검사를 사용하는 서로 다른 방식이 공존합니다.

학습할 때는 main/boards/bread-compact-wifi/compact_wifi_board.cc를 한 보드 사례로 고르면 좋습니다. 생성자에서 디스플레이·버튼·도구를 초기화하고, GetAudioCodec()에서 I2S 연결 방식에 맞는 코덱 구현을 반환합니다. NoAudioCodecSimplex라는 이름은 오디오 자체가 없다는 뜻이 아니라, 별도 제어형 코덱 칩 없이 쓰는 I2S 경로입니다.

4-4. 패턴 ③ 통신 전략 교체와 공통 메시지 계약

main/protocols/protocol.h는 OpenAudioChannel(), SendAudio(), CloseAudioChannel()을 순수 가상 함수로 정의합니다. Application::InitializeProtocol()은 OTA 응답에서 MQTT 설정을 먼저 확인하고, 없으면 WebSocket 설정을 확인합니다. 둘 다 없으면 MQTT를 선택합니다.

구현제어 메시지음성 데이터핵심 함수
WebSocket텍스트 프레임의 JSON바이너리 프레임의 OpusWebsocketProtocol::OpenAudioChannel()·SendAudio()
MQTT + UDPMQTT의 JSONAES-CTR 처리한 UDP 패킷MqttProtocol::OpenAudioChannel()·CryptAesCtr()

Protocol::SendMcpMessage()는 JSON-RPC 본문을 type: "mcp" 봉투 안에 넣습니다. 즉 MCP가 오디오 압축을 대체하거나, 모든 데이터를 MQTT로 보내는 구조가 아닙니다. 메시지 의미와 전송 수단을 나눠 둔 것입니다.

WebSocket의 GetHelloMessage()는 format: "opus", sample_rate: 16000, channels: 1, frame_duration: 60을 알립니다. OpenAudioChannel()은 서버의 hello를 최대 10초 기다립니다. 반대로 서버가 보낼 음성의 기본값은 Protocol에서 24 kHz·60 ms이며, 실제 응답값으로 갱신할 수 있습니다.

MQTT/UDP 수신 경로는 16바이트 헤더의 길이·종류·페이로드 길이를 검사합니다. 이미 받은 번호 이하의 패킷은 버리고, 순번이 건너뛰면 경고한 뒤 새 패킷을 처리합니다. 이것은 패킷 정렬 버퍼나 재전송 구현과는 다릅니다.

MQTT/UDP 음성 패킷 헤더: 총 16바이트
+------+-------+-------------+------+-----------+----------+
| type | flags | payload_len | ssrc | timestamp | sequence |
| 1 B  | 1 B   | 2 B         | 4 B  | 4 B       | 4 B      |
+------+-------+-------------+------+-----------+----------+
| 이어지는 데이터: 암호화된 Opus payload                 |
+--------------------------------------------------------+

4-5. 패턴 ④ 생산자·소비자와 용량 제한 파이프라인

AudioService는 입력·출력·Opus 처리를 별도 태스크로 나눕니다. AudioInputTask()가 녹음하면 음성 엔진이 처리하고, OpusCodecTask()가 압축하거나 압축을 풀며, AudioOutputTask()가 스피커로 내보냅니다.

큐설정상 상한포화 시 정책
인코딩 대기PCM 작업 2개PushTaskToEncodeQueue()에서 가장 오래된 작업을 버립니다.
송신 대기2400 / 60 = 40패킷오래된 패킷을 버리고 새 패킷을 넣습니다.
일반 디코딩 대기1200 / 60 = 20패킷wait가 참이면 공간을 기다리고, 거짓이면 실패를 반환합니다.
재생 대기PCM 작업 2개공간이 생길 때까지 디코딩 진행을 제한합니다.

상한은 main/audio/audio_service.h에 있습니다. 40패킷은 60 ms 전송 프레임 기준 최대 2.4초 분량이고, 이것이 곧 실제 왕복 지연이라는 뜻은 아닙니다. 디코딩 큐의 내부 저장 용량은 10초 오디오 테스트 큐와 교환할 수 있게 더 크게 잡혀 있지만, 일반 수신의 제한은 20패킷입니다. 서버가 다른 프레임 길이를 쓰면 패킷 수와 초 단위 관계도 달라집니다.

실시간 음성에서는 오래된 소리를 전부 보존하는 것보다, 메모리와 지연이 끝없이 늘어나지 않게 하는 선택이 더 중요할 수 있습니다. 이 코드의 송신·인코딩 경로는 그 선택을 명시합니다. 반면 재생 쪽에는 기다리는 정책이 있으므로, 모든 큐가 같은 방식으로 동작한다고 일반화하면 안 됩니다.

main/audio/fixed_queue.h의 FixedQueue는 std::array<std::optional<T>, Capacity>를 사용합니다. 큐 노드가 계속 늘어나는 할당은 피하지만, 패킷의 std::vector 버퍼와 패킷 객체 할당까지 사라지는 것은 아닙니다. “고정 용량 큐”와 “할당 없는 전체 경로”를 구별하세요.

재생 취소도 흥미롭습니다. ResetDecoder()는 playback_generation_을 올리고 큐를 비웁니다. PushPacketToDecodeQueue()는 기다리는 동안 세대가 바뀌면 실패합니다. IsPlaybackDrainedLocked()는 큐뿐 아니라 디코딩·출력 중인 작업도 확인하므로, 큐가 비었다는 이유만으로 소리가 끝났다고 판단하지 않습니다.

4-6. 실제 추적: “볼륨을 30으로 바꾸기”

다음은 서버가 보내는 MCP 도구 호출의 예시입니다. session_id는 설명용 값이며, 자연어에서 도구를 선택하는 과정은 서버의 역할입니다. 기기는 아래처럼 구조화된 요청을 처리합니다.

{
  "session_id": "example-session",
  "type": "mcp",
  "payload": {
    "jsonrpc": "2.0",
    "id": 7,
    "method": "tools/call",
    "params": {
      "name": "self.audio_speaker.set_volume",
      "arguments": { "volume": 30 }
    }
  }
}
  1. Application::InitializeProtocol()에 등록된 JSON 콜백이 type == "mcp"를 보고 McpServer::ParseMessage(payload)를 호출합니다.
  2. ParseMessage()는 JSON-RPC 버전 2.0, 메서드, 객체형 인자, 숫자형 요청 ID를 검사합니다.
  3. DoToolCall()이 도구 이름을 찾아 PropertyList를 복사하고 필수 인자·타입·범위를 확인합니다. 볼륨의 선언 범위는 0~100입니다.
  4. 검증된 인자를 값으로 보관한 작업이 Application::Schedule()에 등록됩니다. 수신 JSON 포인터를 나중까지 그대로 붙잡는 방식이 아닙니다.
  5. 메인 태스크에서 콜백이 실행되어 Board::GetInstance().GetAudioCodec()->SetOutputVolume(30)으로 이어집니다.
  6. ReplyResult()가 같은 ID로 응답을 만들고 Protocol::SendMcpMessage() 경로로 돌려보냅니다.

initialize 응답은 protocolVersion: "2024-11-05"를 선언합니다. 도구 목록은 GetToolsList()가 8,000바이트를 기준으로 페이지를 나누고 nextCursor를 보냅니다. MCP의 모든 기능을 구현한 범용 서버로 가정하기보다, initialize·tools/list·tools/call 중심의 장치용 구현으로 읽는 것이 정확합니다.

용어
프로토콜과 전송 수단
“볼륨을 30으로 바꿔라”라는 요청의 형식은 메시지 계약이고, 그 요청을 실어 나르는 길이 WebSocket이나 MQTT입니다. MCP 명세는 JSON-RPC 2.0으로 도구를 발견하고 호출하는 공통 규칙을 정의합니다. 이 펌웨어는 그 메시지를 자체 대화 봉투 안에 싣습니다.

5디렉토리 구조 해부

전체 크기에 압도되지 않도록, 공통 코어와 보드 변형을 나눠 읽습니다
xiaozhi-esp32/
├── CMakeLists.txt               프로젝트·SDK 진입점
├── sdkconfig.defaults*         공통·칩별 기본 설정
├── main/
│   ├── main.cc                 app_main() 진입점
│   ├── application.*           이벤트 루프와 대화 동작
│   ├── device_state*           상태 이름·전이 규칙
│   ├── mcp_server.*            도구 등록·검증·실행 요청
│   ├── ota.*                   버전 조회·활성화·업데이트
│   ├── settings.*              NVS 설정 접근
│   ├── idf_component.yml       외부 컴포넌트 의존성
│   ├── Kconfig.projbuild       보드·언어·기능 설정
│   ├── CMakeLists.txt          소스·리소스 선택
│   ├── audio/
│   │   ├── audio_service.*     오디오 태스크와 큐
│   │   ├── audio_codec.*       하드웨어 오디오 인터페이스
│   │   ├── fixed_queue.h      고정 용량 큐
│   │   ├── codecs/            하드웨어별 코덱 구현
│   │   └── engines/           AFE·경량 음성 엔진
│   ├── protocols/             공통 API·WebSocket·MQTT/UDP
│   ├── boards/
│   │   ├── common/            보드·버튼·통신 공통 계층
│   │   └── bread-compact-wifi/ 한 보드의 구현·핀·변형
│   ├── display/               OLED·LCD·LVGL 화면
│   ├── led/                   LED 표시
│   ├── notify/                알림 음성·자막 재생
│   └── assets/                효과음·언어 리소스
├── partitions/v2/              플래시 분할표
├── scripts/
│   ├── build.py               보드 변형 빌드 도구
│   ├── build_default_assets.py 리소스 묶음 생성
│   ├── gen_lang.py            언어 헤더 생성
│   ├── release.py             릴리스 준비·검증·게시
│   ├── ci/                    대표 빌드 변형 목록
│   └── tests/                 호스트 측 테스트
├── docker/firmware-builder/    펌웨어 빌더 컨테이너
├── .github/workflows/          빌드·릴리스·MCP 검사
└── docs/                       보드·통신·MCP 문서

5-1. 폴더보다 “한 기능의 경로”를 따라가기

궁금한 동작읽는 순서
버튼으로 대화 시작보드의 InitializeButtons() → Application::ToggleChatState() → Run() → HandleToggleChatEvent()
음성 전송AudioInputTask() → 음성 엔진의 출력 → 인코딩 큐 → OpusCodecTask() → 송신 큐 → Protocol::SendAudio()의 실제 구현
응답 자막통신의 JSON 수신 → InitializeProtocol()의 tts/sentence_start 분기 → Schedule() → SetChatMessage()
조명 제어보드의 InitializeTools() → main/boards/common/lamp_controller.h → MCP 등록 → gpio_set_level()
보드 선택보드의 config.json → scripts/build.py → Kconfig → CMake → 보드 소스와 config.h

5-2. 코드 규모는 무엇을 포함한 숫자인가

커밋 b3ff05c의 Git 추적 파일을 확장자별로 분류한 물리적 줄 수입니다. 빈 줄·주석을 포함하며, 다운로드한 외부 컴포넌트와 빌드 산출물은 포함하지 않습니다. 헤더에는 C/C++ 선언과 데이터가 함께 있으므로 C++ 구현 수치와 분리했습니다.

분류파일 수줄 수
C++ 구현 (.cc·.cpp)28786,089
헤더 (.h·.hpp)35729,541
C 구현 (.c)186,657
Python (.py)3112,770
CMake21,530
Shell (.sh)213

이 기준의 C/C++ 구현·헤더 합계는 122,287줄입니다. main/boards/ 아래 관련 코드만 103,064줄인 반면, main/audio/는 5,502줄, main/protocols/는 1,265줄입니다. 전체 줄 수의 상당 부분은 보드 지원에 있으므로, 핵심 원리를 공부하려면 공통 계층부터 좁혀 읽는 편이 좋습니다.

Python 테스트 파일 10개의 합계는 3,946줄입니다. 이는 테스트가 존재하는 범위를 보여 주는 수치이며, 펌웨어의 전체 테스트 커버리지나 실제 장치 품질을 나타내는 비율은 아닙니다.

첫 독서 목표
한 보드·한 전송 방식·한 도구를 끝까지

bread-compact-wifi, WebSocket, 볼륨 도구 세 가지를 고르면 충분합니다. 이 경로를 설명할 수 있게 된 뒤 다른 코덱·모뎀·화면 구현을 비교하면, 반복되는 인터페이스와 달라지는 하드웨어 코드를 쉽게 구분할 수 있습니다.

6학습 포인트 (기술별)

기능을 읽은 뒤, 다른 프로젝트에서도 쓸 수 있는 원리를 꺼내 봅니다

6-1. C++23: 객체의 주인이 누구인지 추적하기

AudioStreamPacket은 std::unique_ptr로 전달됩니다. std::move(packet)가 보이면 “비싼 데이터를 복사하지 않는다”에서 멈추지 말고, 이제 누가 패킷을 파괴할 책임을 가지는가를 질문하세요. Protocol::SendAudio()와 AudioService::PopPacketFromSendQueue()가 좋은 읽기 대상입니다.

main/mcp_server.h는 성공 시 반환할 값의 타입을 std::variant로, 성공 또는 오류를 std::expected로 표현합니다. sdkconfig.defaults는 C++ 예외와 RTTI를 끄므로, 예외 대신 반환값으로 실패를 전달하는 방식도 함께 볼 수 있습니다.

// main/mcp_server.h
using ReturnValue = std::variant<bool, int, std::string, cJSON*, ImageContent*>;
using ToolResult = std::expected<ReturnValue, std::string>;
using ToolCallback = std::function<ToolResult(const PropertyList&)>;

미니 실습: DoToolCall()에서 arguments가 복사되는 지점과 람다로 이동하는 지점을 표시하세요. 원본 수신 JSON이 해제되어도 지연 실행이 가능한 이유를 객체 수명으로 설명해 보세요. 포인터형 반환값에는 별도 소유권 약속도 있으므로, McpTool::Call()의 직렬화·해제 경로까지 확인하면 좋습니다.

용어
RAII와 이동 소유권
RAII는 객체가 만들어질 때 자원을 잡고, 수명이 끝날 때 자동으로 놓는 방식입니다. lock_guard는 범위를 벗어나면 잠금을 풀고, unique_ptr는 소유한 객체를 정리합니다. 이동은 이 책임을 다른 객체에 넘기는 일입니다. 실제 버퍼가 항상 통째로 복사된다는 뜻은 아닙니다.

6-2. FreeRTOS: 신호·큐·잠금을 구분하기

AudioService::Start()에서 입력 태스크 우선순위는 8, 출력은 4, Opus 처리는 2입니다. 오디오 프로세서를 쓸 때 입력은 코어 0에 고정합니다. 메인 루프는 Application::Run()에서 우선순위를 10으로 설정합니다. 이 숫자는 CPU 사용률이나 지연 보장이 아니라, 실행 가능한 태스크 사이의 스케줄링 우선순위입니다.

미니 실습: “네트워크 콜백 → 이벤트 비트 → 메인 루프 → 큐 소비”를 네 칸으로 그리세요. 이벤트 비트가 여러 번 합쳐져도 MAIN_EVENT_SEND_AUDIO 분기에서 while로 큐를 비우는 이유를 설명해 보세요. 공식 배경은 ESP-IDF FreeRTOS 문서에서 이어갈 수 있습니다.

6-3. 디지털 오디오: 단위로 메모리 비용 계산하기

16 kHz·16비트·단일 채널 PCM이면 초당 16,000 × 2 = 32,000바이트입니다. 60 ms는 960샘플, 1,920바이트입니다. 이것은 압축 전 PCM 계산이며, 실제 Opus 전송량은 설정과 음성 내용에 따라 달라집니다.

AS_OPUS_ENC_CONFIG()는 complexity = 0, enable_fec = false, enable_dtx = true, enable_vbr = true를 설정합니다. 자동 비트레이트를 쓰므로 특정 고정 전송률을 임의로 붙이면 안 됩니다. Opus의 배경은 공식 코덱 소개를 참고하세요.

미니 실습: 2초의 PCM이 64,000바이트라는 것을 계산하고, main/audio/README.md의 깨우기 음성 캐시 설명과 비교하세요. 전송 패킷에 담긴 압축 음성 용량을 같은 수치로 추정하면 왜 틀리는지도 적어 보세요.

6-4. 네트워크: 데이터 의미와 바이트 표현 분리하기

BinaryProtocol2·BinaryProtocol3는 서로 다른 바이너리 헤더를 정의합니다. 송신 코드의 htons()·htonl(), 수신 코드의 ntohs()·ntohl()는 다중 바이트 정수의 순서를 맞춥니다. 문자 JSON과 바이너리 음성은 같은 연결 위에서도 파싱 규칙이 다릅니다.

미니 실습: MQTT/UDP 헤더의 필드를 바이트 오프셋으로 적어 보세요. payload_len은 2, timestamp는 8, sequence는 12에서 시작합니다. 헤더 길이를 먼저 검사해야 하는 이유를 포인터 접근과 연결해서 설명하세요.

6-5. MCP: 자연어가 아니라 선언된 도구를 검증하기

Property는 타입, 기본값, 정수 범위, 문자열 최대 길이를 표현합니다. Property::Validate()와 set_value()는 선언을 실행 시 검사로 연결합니다. 도구 설명은 모델이 선택할 때 참고하는 정보이고, 실제 인자 검사는 별개의 책임입니다.

미니 실습: 볼륨 도구에 대해 누락, 문자열 "30", 정수 30, 범위 밖 101의 예상 결과를 표로 만드세요. 도구가 선언한 스키마와 디스패처의 검사를 각각 찾아 근거를 달아 보세요.

정밀하게 보면 한계도 있습니다. DoToolCall()은 정수 인자를 cJSON_IsNumber()로 확인한 뒤 valueint로 읽으며, 소수 부분이 없는지 별도로 검사하지 않습니다. 스키마의 integer 선언만 보고 모든 숫자 입력이 엄격히 검사된다고 가정하면 안 됩니다. 선언과 구현을 비교하는 연습에 좋은 지점입니다.

6-6. 빌드·테스트: 다양한 하드웨어의 공통 부분 검증하기

scripts/tests/test_mcp_server.py는 플랫폼 의존 부분을 대체하되 실제 mcp_server.cc와 press-to-talk 도구를 호스트 C++23 컴파일러로 컴파일하도록 설계되어 있습니다. test_tool_error_message_round_trips()는 따옴표·줄바꿈 등이 들어간 오류도 JSON 왕복이 되는지 검사합니다.

미니 실습: 이 테스트에서 진짜 코드인 부분과 대체한 하드웨어 부분을 두 열로 나눠 보세요. 테스트가 통과하더라도 마이크 배선·음향 에코·무선 끊김을 검증할 수 없는 이유를 정리하세요.

좋은 학습 결과는 “이 라이브러리를 안다”가 아니라, 데이터의 소유자·실행 태스크·실패 정책을 소스에서 설명할 수 있는 상태입니다.

7하드웨어 / 시스템 요구사항

읽기 학습, 빌드, 실제 동작의 요구사항을 따로 봅니다
목표필요한 것주의할 구분
소스 학습텍스트 편집기, Git, JSON·C++를 읽을 기초실제 보드나 모델 API가 없어도 호출 흐름·계산·설계 과제를 할 수 있습니다.
펌웨어 빌드선택한 칩을 지원하는 ESP-IDF, Python과 SDK 도구 체인매니페스트는 IDF 6.0.1 이상, README·CI는 release/v6.1 사용을 안내합니다.
MCP 호스트 테스트C++23 컴파일러, Python, cJSON·Mbed TLS 개발 라이브러리.github/workflows/test-mcp.yml은 Ubuntu 24.04에서 g++·libcjson-dev·libmbedtls-dev를 사용합니다.
음성 단말 동작맞는 ESP32 보드, 마이크·출력 회로·스피커, 전원, 네트워크, 호환 서버코드가 컴파일되는 것과 보드의 배선·음향이 정상인 것은 별도 조건입니다.
선택 기능화면, 카메라, PSRAM, AEC 기준 신호, 모뎀 등보드·칩·기능별 조건이 다릅니다. 공통 필수 부품 목록이 아닙니다.

7-1. 메모리: 16 MB 플래시는 기본값이지 절대 최소값이 아닙니다

sdkconfig.defaults의 공통값은 16 MB 플래시와 partitions/v2/16m.csv입니다. 반면 main/boards/espressif/esp-hi/config.json은 4 MB 플래시와 partitions/v2/4m.csv를 선택합니다. 따라서 “이 프로젝트는 무조건 16 MB가 필요하다”는 설명은 틀립니다.

16 MB 기본 분할실제 CSV 값역할
nvs0x4000 = 16 KiB설정 저장
otadata0x2000 = 8 KiBOTA 슬롯 상태
ota_0·ota_1각 0x3f0000 = 3.9375 MiB번갈아 사용하는 펌웨어 영역
assets0x800000 위치, 크기 8 MiB폰트·표정·깨우기 모델 등의 리소스

partitions/v2/README.md는 앱 영역을 약 4 MB로 요약하지만 실제 크기는 위 CSV 값입니다. 또한 4 MB 분할은 factory 앱 하나와 1 MiB 리소스를 사용하므로, 모든 보드가 두 OTA 슬롯을 가진다고 가정할 수 없습니다.

용어
플래시와 PSRAM
플래시는 전원을 꺼도 남는 저장 공간이고, PSRAM은 실행 중 데이터를 담는 추가 RAM입니다. 플래시가 크다고 음성 처리용 RAM이 자동으로 넉넉해지지는 않습니다. AFE 기반 깨우기와 사용자 깨우기 경로는 Kconfig에서 S3·P4·S31 및 PSRAM 조건을 요구합니다.

7-2. 오디오: 단자 수보다 신호의 의미가 중요합니다

main/boards/bread-compact-wifi/config.h는 입력 16 kHz·출력 24 kHz를 사용합니다. 기본 simplex 배선은 마이크 WS/SCK/DIN이 GPIO 4/5/6, 스피커 DOUT/BCLK/LRCK가 GPIO 7/15/16입니다. 이 값은 그 보드 정의의 예시이며 다른 보드에 그대로 적용할 공통 핀 표가 아닙니다.

AfeAudioEngine의 채널 형식에서 M은 마이크, R은 재생 기준 신호입니다. 두 번째 마이크를 R로 연결하면 같은 의미가 되지 않습니다. USE_DEVICE_AEC도 기본값은 꺼져 있고 지원 보드 목록으로 제한됩니다. 서버 AEC 옵션에는 불안정 표시와 서버 지원 조건이 있습니다.

전이중 대화는 옵션 하나의 효과가 아니라, 재생 기준 신호·음향 구조·보드 지원·서버 동작이 함께 맞아야 가능한 결과입니다. 현재 AFE 코드는 ns_init = false로 잡음 억제를 끄고 있으므로, AFE 사용을 곧 모든 잡음 제거 기능 활성화로 설명하면 안 됩니다.

7-3. 연결·운영: 깨우기는 로컬, 대화는 서버 연동

깨우기 단어 인식은 기기에서 할 수 있지만, 일반적인 자유 대화에는 호환 대화 서버가 필요합니다. Ota::GetCheckVersionUrl()는 NVS의 wifi/ota_url을 먼저 읽고, 없으면 CONFIG_OTA_URL을 사용합니다. 기본 URL은 main/Kconfig.projbuild에 정의되어 있고 버전·서버 주소 조회에 쓰입니다.

서버를 바꿀 때는 접속 주소 하나뿐 아니라 OTA 응답 형식, 인증, hello, 음성 프레임, 제어 메시지, MCP 호환성을 함께 봐야 합니다. 특정 GPU가 이 펌웨어의 필수 요구사항은 아니며, 서버를 직접 운영할 경우 필요한 자원은 선택한 모델과 서버 구현에 달려 있습니다.

8직접 해볼 수 있는 실습 과제

장치 없이 시작할 수 있는 과제부터, 보드 이식 설계까지 다섯 단계

모든 과제에서 입력·중간 경로·예상 결과를 먼저 적으면, 코드를 바꾸기 전에 이해의 빈틈을 찾을 수 있습니다. 아래 1~3번은 빌드 없이 완료할 수 있습니다. 4~5번은 설계와 예제 작성까지 진행하고, 실행 검증은 별도의 개발 환경과 필요 하드웨어를 갖춘 뒤 확장하세요.

과제 1 · 소스 탐색

버튼 하나에서 대화 상태까지 입문

목표: UI 이벤트가 코어 로직으로 들어가는 경로를 이해합니다.

  1. bread-compact-wifi의 InitializeButtons()에서 부트 버튼 콜백을 찾습니다.
  2. 부팅 중 눌렀을 때와 일반 대화 상태에서 눌렀을 때의 분기를 나눕니다.
  3. ToggleChatState() → 이벤트 비트 → Run() → HandleToggleChatEvent()를 연결합니다.

산출물: 함수 이름과 파일 경로가 적힌 5칸 흐름도. 완료 기준: 버튼 콜백이 곧바로 네트워크 연결을 수행하는지, 메인 루프에 요청하는지 근거를 들어 설명할 수 있습니다.

# 소스만 검색하는 명령
rg -n 'InitializeButtons|ToggleChatState' main/boards/bread-compact-wifi
rg -n 'MAIN_EVENT_TOGGLE_CHAT|HandleToggleChatEvent' main/application.*
과제 2 · 설정과 리소스

한국어 UI가 선택되는 길 추적하기 입문

목표: 설정 한 줄이 코드와 자원 선택으로 이어지는 과정을 배웁니다.

  1. main/Kconfig.projbuild에서 LANGUAGE_KO_KR를 찾습니다.
  2. main/CMakeLists.txt에서 LANG_DIR "ko-KR"와 LANG_JSON을 찾습니다.
  3. main/assets/locales/ko-KR/language.json의 키 세 개와 대응하는 화면 문구를 골라 번역표를 만듭니다.
  4. 언어별 OGG 파일이 없을 때 영어 파일을 보충하는 CMake 분기를 찾아 표시합니다.

산출물: 설정 → 디렉토리 → JSON → 생성 헤더의 연결도와 문구 3개. 완료 기준: 한국어 UI 설정과 한국어 음성 인식 모델·한국어 깨우기 단어가 서로 다른 설정이라는 점을 설명할 수 있습니다.

과제 3 · 실시간 큐 계산

네트워크가 멈춘 3초를 종이에 재현하기 중급

목표: 용량 제한과 오래된 데이터 폐기의 효과를 계산합니다.

  1. 송신 큐가 비어 있고 소비는 멈췄다고 가정합니다. 60 ms마다 패킷 1개가 들어옵니다.
  2. 번호 1~50의 패킷을 넣으며, 상한 40개를 넘을 때 앞의 것을 버리는 표를 만듭니다.
  3. 실제 OpusCodecTask()의 송신 큐 분기와 비교합니다.

산출물: 시점·입력 번호·큐 앞/뒤 번호·폐기 수 표. 완료 기준: 최종 큐는 11~50번, 폐기 10개, 보관 분량은 2.4초가 됩니다. 압축률·패킷 손실·입력 큐 지연은 이 단순 모형에서 제외했다고 명시하세요.

과제 4 · MCP 스키마 설계

하드웨어를 건드리지 않는 덧셈 도구 중급

목표: 도구의 설명·인자 스키마·콜백을 한 세트로 설계합니다.

  1. self.study.add라는 도구 초안을 작성합니다. 두 입력의 스키마는 -100~100의 정수로 선언합니다.
  2. 아래 예시를 읽고, 이를 공통 도구 목록보다 보드의 InitializeTools() 쪽에 두는 이유를 정리합니다.
  3. 정상·누락·타입 오류·범위 오류 요청을 각각 JSON으로 작성합니다.

산출물: 도구 등록 코드 초안과 요청·예상 응답 4쌍. 완료 기준: 아래 예시의 누락·타입·범위 오류는 콜백 실행 전 거부되며, 정상 입력의 합은 -200~200 범위임을 설명합니다. 소수 입력에 대한 검사 한계는 6-5절과 함께 보세요.

// 연습용 도구 등록 예시
auto& server = McpServer::GetInstance();
server.AddTool(
    "self.study.add", "두 정수를 더합니다.",
    PropertyList({
        Property("left", kPropertyTypeInteger, -100, 100),
        Property("right", kPropertyTypeInteger, -100, 100)
    }),
    [](const PropertyList& args) -> ReturnValue {
        return args["left"].value<int>() + args["right"].value<int>();
    });
입력 예시예상 동작
{"left": 2, "right": 3}성공. 결과 5를 도구 응답의 텍스트 콘텐츠로 직렬화합니다.
{"left": 2}필수 인자 누락, JSON-RPC 오류 코드 -32602
{"left": "2", "right": 3}인자 타입 불일치, -32602
{"left": 101, "right": 3}인자 범위 초과, -32602

참고할 테스트는 scripts/tests/test_mcp_server.py입니다. 새 도구를 실제로 검사하려면 테스트 드라이버에 등록하고 요청을 보내는 구성이 필요합니다. 위 표는 디스패처 로직에 따른 예상 결과이며 실행 측정값은 아닙니다.

과제 5 · 보드 이식 설계

가상의 새 보드를 하나의 제품 변형으로 정의하기 고급

목표: 핀 번호 몇 개를 바꾸는 것보다 넓은 보드 이식의 범위를 익힙니다.

  1. docs/custom-board.md와 기존 보드 하나를 읽고, 고유한 보드 디렉토리·type·빌드 name을 설계합니다.
  2. 칩·플래시 분할·PSRAM·입출력 샘플레이트·화면·네트워크를 표로 명시합니다.
  3. config.json, Kconfig, CMake, config.h, 보드 클래스, DECLARE_BOARD의 변경 계획을 작성합니다.
  4. 녹음·재생·깨우기·대화 중단·재연결·선택 AEC를 검증할 절차와 관찰 항목을 적습니다.

산출물: 보드 이식 설계서 1장과 파일별 변경 계획. 완료 기준: 기존 보드 핀을 덮어쓰지 않고, OTA 호환성에 쓰이는 보드 식별자를 구분하며, 해당 빌드에서 팩터리가 하나만 생기도록 설명합니다.

실습 순서
먼저 설명하고, 그다음 실행으로 확인하기

1~3번으로 호출 경로와 단위를 익힌 뒤 4번에서 입력 계약을, 5번에서 제품 구성을 다루세요. 실행 환경을 갖췄다면 설계한 예상 결과를 실제 관찰과 비교하되, 빌드 성공과 하드웨어 성공은 따로 기록하는 습관이 좋습니다.

9관련 기술 심화 학습 로드맵

6주 동안 “한 번의 대화를 설명할 수 있는 개발자”가 되는 순서

기초 C++ 문법과 함수 호출을 읽을 수 있다면 시작할 수 있습니다. 처음 보는 문법이 많으면 class, 참조, 람다, 템플릿을 먼저 복습하세요. 주차는 속도 경쟁을 위한 기한이 아니라, 다음 주제로 넘어갈 때 확인할 이해 기준입니다.

주차학습 주제·소스만들 결과와 통과 기준
1주차펌웨어 입구, 함수 호출, 설정
main/main.cc·main/application.*·보드 config.json
부팅 흐름과 버튼 흐름도. 진입점, 네트워크 시작, 메인 루프를 구별합니다. 파일은 main/ 아래에서 찾습니다.
2주차현대 C++ 소유권과 실패 표현
main/protocols/protocol.h·main/mcp_server.h
패킷 수명도와 ToolResult 해설. 참조·이동·소유 포인터의 차이를 한 사례로 설명합니다.
3주차FreeRTOS·이벤트·상태 머신
main/application.cc·main/device_state_machine.cc
상태 전이표와 태스크별 책임표. 잘못된 전이와 잠금 안의 느린 작업이 왜 문제인지 설명합니다.
4주차PCM·Opus·큐 정책
main/audio/audio_service.*·main/audio/fixed_queue.h
60 ms 프레임의 메모리 계산과 송신 큐 모형. 압축 전 바이트 수와 압축 후 길이를 구분합니다.
5주차WebSocket·MQTT/UDP·MCP
main/protocols/·main/mcp_server.cc
도구 호출 요청·응답 예제와 오류 표. 서버가 도구를 고르는 일과 기기가 실행하는 일을 분리합니다.
6주차보드 추상화·빌드·검증
main/boards/common/·scripts/build.py·CI
보드 이식 설계서와 검증 계획. 공통 변경이 어떤 대표 변형에 영향을 주는지 설명합니다.

각 주차의 결과에 파일 경로·함수 이름·입력과 출력의 형식을 함께 적어 두세요. 다음 주에 다른 계층을 읽을 때, 이미 이해한 연결 지점을 다시 사용할 수 있습니다.

9-1. 더 깊이 들어갈 세 갈래

다음 단계로 넘어갈 기준은 파일을 다 읽었는지가 아니라, 하나의 입력이 어느 태스크와 큐를 거쳐 어떤 출력이 되는지 설명할 수 있는지입니다. 음성이나 화면이 없는 프로젝트에서도 이 기준은 그대로 쓸 수 있습니다.

학습 비유

도시 전체의 골목을 외우기보다 집에서 학교까지의 길 하나를 먼저 익히는 방식입니다. 한 경로를 정확히 알면, 다른 보드와 다른 통신 방식은 “어느 교차로에서 갈라지는가”라는 비교 문제로 바뀝니다.

10핵심 키워드 사전

이 글의 소스 코드를 다시 읽을 때 곁에 둘 용어
용어쉬운 설명
ESP32기기의 입력·출력과 통신을 제어하는 마이크로컨트롤러 계열. 칩별 자원과 기능이 다릅니다.
ESP-IDFESP32용 공식 개발 프레임워크. 드라이버·RTOS·빌드 도구 등을 제공합니다.
펌웨어기기의 플래시에 기록되어 부팅 후 하드웨어를 제어하는 프로그램입니다.
FreeRTOS 태스크스케줄러가 번갈아 또는 여러 코어에서 실행하는 작업 단위. 이 코드에서는 녹음·재생·코덱 처리를 나눕니다.
이벤트 그룹여러 종류의 신호를 비트로 모아 기다리는 FreeRTOS 기능. 같은 비트는 횟수별로 쌓이지 않습니다.
상태 머신현재 상태와 허용된 이동 규칙으로 동작을 관리하는 구조. 듣기·말하기·연결 중 같은 상태를 제한합니다.
콜백이벤트가 생겼을 때 호출하도록 미리 등록한 함수. 어느 태스크에서 불리는지도 중요합니다.
뮤텍스공유 데이터를 동시에 바꾸지 않도록 한 번에 하나의 실행 흐름에 접근을 허용하는 잠금입니다.
생산자·소비자데이터를 만드는 작업과 사용하는 작업을 큐로 연결하는 방식. 처리 속도가 달라도 조율할 수 있습니다.
포화 정책큐가 가득 찼을 때 기다릴지, 새 데이터를 거절할지, 오래된 데이터를 버릴지 정한 규칙입니다.
PCM시간마다 측정한 소리의 진폭을 숫자로 담은 데이터. 압축 전 오디오 표현입니다.
샘플레이트1초에 소리를 몇 번 측정하는지 나타내는 값. 16 kHz는 초당 16,000개 샘플입니다.
Opus음성·음악 전송에 쓰는 오디오 압축 형식. 이 펌웨어는 기본 60 ms 단위로 인코딩합니다.
VAD현재 오디오에 사람의 발화가 있는지 감지하는 기능. 무엇을 말했는지 알아듣는 ASR과 다릅니다.
AFE인식·전송 전에 오디오를 처리하는 전처리 계층. 기능별 활성화 여부는 설정과 구현에 달려 있습니다.
AEC스피커에서 나와 마이크에 다시 들어온 소리를 줄이는 에코 제거. 재생 기준 신호가 중요합니다.
WakeNet·MultiNetESP-SR의 음성 모델 계열. 이 프로젝트에서는 깨우기 감지와 사용자 깨우기 단어 경로에 활용합니다.
ASR·TTSASR은 말을 글자로, TTS는 글자를 말소리로 바꾸는 기술. 일반 대화 경로에서는 서버가 담당합니다.
LLM문맥을 바탕으로 답변과 도구 호출 등을 생성하는 언어 모델. 이 저장소는 주로 그 결과를 받아 동작합니다.
MCPAI 애플리케이션과 도구·데이터를 연결하는 공통 규칙. 여기서는 ESP32가 도구를 제공하는 서버 역할을 합니다.
JSON-RPC 2.0JSON으로 함수 이름·인자·요청 ID·결과 또는 오류를 주고받는 메시지 형식입니다.
도구 스키마도구 이름과 인자의 타입·필수 여부·범위를 적은 계약. 실행 전에 입력을 검증하는 근거입니다.
WebSocket연결을 유지하면서 양쪽이 메시지를 보낼 수 있는 통신 방식. 여기서는 JSON과 바이너리 음성을 나눠 보냅니다.
MQTT·UDPMQTT는 발행·구독형 메시지 통신, UDP는 데이터그램 전송 방식. 이 프로젝트는 제어와 음성 경로로 조합합니다.
AES-CTR·nonceAES-CTR은 암호화 방식이고 nonce는 암호화 입력 구성에 쓰는 값입니다. 암호화가 곧 메시지 인증을 의미하지는 않습니다.
I2S·I2CI2S는 디지털 오디오 전달, I2C는 디스플레이·코덱 등의 제어에 흔히 쓰는 하드웨어 통신 규격입니다.
HAL·팩터리HAL은 하드웨어 차이를 감추는 인터페이스, 팩터리는 사용할 구체 객체를 만드는 함수입니다.
NVS재부팅 후에도 남는 키·값 저장소. Wi-Fi 설정, 장치 UUID, 접속 설정 등에 사용합니다.
OTA·파티션OTA는 네트워크 업데이트, 파티션은 플래시의 구역입니다. 앱·설정·리소스를 다른 구역에 배치합니다.
PSRAM기기에 추가된 실행용 RAM. 플래시 용량과 다르며, 음성 모델·화면 버퍼 등의 조건에 영향을 줍니다.
RAII·unique_ptr객체 수명에 자원 해제를 묶는 원칙과, 객체 하나의 소유 책임을 관리하는 C++ 포인터입니다.
std::expected성공 결과나 오류 중 하나를 반환하는 C++ 타입. 이 코드에서는 MCP 실행 실패 등을 전달합니다.
Kconfig·빌드 변형Kconfig는 기능 설정 체계, 빌드 변형은 칩·화면·메모리 등의 조합으로 구별하는 펌웨어 구성입니다.
LVGL임베디드 기기에서 화면 위젯·스타일·글꼴 등을 다루는 그래픽 UI 라이브러리입니다.

처음에는 태스크·큐·상태·소유권·도구 스키마 다섯 단어부터 정확히 연결해 보세요. 나머지 용어는 이 다섯 개가 움직이는 환경과 데이터 형식을 설명합니다.

11참고 링크

고정된 코드 근거, 공식 기술 문서, 그리고 현재 상태

11-1. 공식 저장소와 개발 문서

11-2. 핵심 소스 바로가기

11-3. 관련 기술을 이어서 공부할 문서

별도 서버가 궁금하면 README가 소개하는 Python 서버 프로젝트부터 역할의 차이를 비교해 보세요. 서버의 내부 구조와 지원 펌웨어 버전은 해당 프로젝트의 문서를 참고하세요.

현재 상태 (2026-10-23 확인)

항목상태와 기준
선정GitHub Trending C++ Weekly #2 · 2026-10-23
스타 / 포크31,804 / 7,436 · 2026-10-23 GitHub 기준
주 언어 / 설명C++ · MCP 기반 챗봇
생성일2024-08-31
최근 push2026-10-23 · GitHub 기준
최신 릴리스v2.5.2 · 2026-10-11
분석 기준 최근 커밋b3ff05c · 2026-10-23 · Merge pull request #2263 from goodtft/main
소스의 개발 버전2.5.3 · 루트 CMakeLists.txt
코드 규모C++ 구현 86,089줄, 헤더 29,541줄, C 6,657줄, Python 12,770줄. 빈 줄·주석 포함, Git 추적 파일 기준
보드·변형보드 config.json 159개, 그 안의 builds 항목 합계 193개. SDK 조건 적용 전 원시 집계
언어 리소스language.json 40개, CMake 언어 매핑도 40개. 실제 안내 음성의 구비 여부와는 별도
SDKESP-IDF >=6.0.1 · 권장 브랜치 release/v6.1 · S31은 README 기준 6.1 이상
테스트 코드scripts/tests/test_*.py 10개, 3,946줄. 실행 성공·장치 검증을 뜻하지 않음
라이선스MIT · 의존 컴포넌트의 라이선스는 별도 확인 대상

README와 집계 차이: README에는 보드 디렉토리 138개·릴리스 변형 171개·UI 언어 39개로 적혀 있습니다. 분석 커밋의 설정·리소스 원시 집계는 위 표와 다릅니다. _get_builds_for_idf()는 변형의 idf_version 조건을 필터링하므로, 원시 193개를 특정 SDK에서 검증된 릴리스 수로 바꾸어 말할 수는 없습니다.

쓰기 전에 알아둘 한계
기기 펌웨어의 소스 이해와 완제품의 동작 보장은 다릅니다

동작·성능: 호출 흐름과 설정은 커밋 b3ff05c 기준입니다. 실제 빌드·호스트 테스트 결과와 보드의 지연·음질·소비 전력은 개발 환경과 하드웨어에 따라 달라집니다. 전체 기여자 수·주간 증가량은 전체 이력과 기간별 통계가 필요한 별도 지표입니다.

서버 경계: 모델 추론·화자 인식·클라우드 MCP와 공식 서버의 무료 이용 안내는 README의 서비스 설명입니다. 서버 소스·모델 가중치·모든 외부 도구가 이 저장소에 들어 있는 것은 아니며, 서비스 조건과 서버 호환성은 별도 확인이 필요합니다.

하드웨어 경계: 칩 지원 목록이 모든 보드의 카메라·AEC·PSRAM 지원을 의미하지 않습니다. UI 언어가 있다는 사실도 해당 언어의 로컬 깨우기 모델을 보장하지 않습니다. 실제 보드의 핀·전원·오디오 기준 신호와 Kconfig 조건을 함께 확인하세요.

MCP·통신 경계: 현재 디스패처는 숫자형 요청 ID와 일부 메서드를 처리합니다. user_only는 도구 목록 필터이며, DoToolCall() 자체의 사용자 인증 장벽으로 볼 수 없습니다. MQTT/UDP의 길이 검사를 근거로 다른 전송 경로까지 같은 검증을 갖췄다고 가정해서도 안 됩니다.

버전 경계: 공개 릴리스와 개발 커밋의 설정은 다를 수 있습니다. 의존성 범위·가변 SDK 브랜치는 시간이 지나면 해석 결과가 바뀔 수 있으므로, 재현할 때는 펌웨어 커밋·SDK 리비전·의존성 잠금 정보를 함께 기록해야 합니다.