Search Code

Makerfabs MaTouch ESP32-S3 2.8인치 카메라 ESP32-S3에서 AI 음성 비서 구축하기 (Azure + DeepSeek)

Makerfabs MaTouch ESP32-S3 2.8인치 카메라 ESP32-S3에서 AI 음성 비서 구축하기 (Azure + DeepSeek)

버튼을 누르고 질문하면, 보드가 소리로 답합니다

작은 보드 하나에 완성된 음성 비서. SPEAK 버튼을 누른 채 질문하세요. 보드는 두 개의 마이크로 사용자를 녹음하고, 오디오를 Microsoft Azure로 보내 텍스트로 변환하며, 그 텍스트를 DeepSeek에 보내 생각하게 하고, 답변을 다시 Azure로 보내 음성으로 변환한 뒤 자체 스피커로 재생합니다. 전체 대화는 화면에 채팅 버블로 표시됩니다.

Talking AI Voice Assistant running on the MaTouch AI ESP32-S3 board MaTouch AI ESP32-S3 보드에서 실행 중인 말하는 AI 음성 비서

버튼을 누르는 순간부터 일어나는 일

한 가지 질문의 전체 과정을 단계별로 설명합니다. 한 번 읽어볼 가치가 있습니다. 화면과 LED에 표시되는 모든 것이 이 단계 중 하나에 해당하기 때문입니다.

  1. SPEAK 버튼을 누르고 유지합니다. 탭하여 말하는 방식이 아니라 누르고 있는 동안 말하는 방식입니다. 손가락을 누르고 있는 시간만큼 정확히 녹음되며, 최대 6초까지 가능합니다. 상태 LED가 파란색으로 바뀌고 버튼에 LISTENING이 표시됩니다.

  2. 두 개의 마이크가 사용자를 녹음합니다. 보드는 스테레오 쌍에서 초당 16,000회 샘플링하고, 두 채널을 하나로 평균화한 후 약간의 게인을 적용하고, 결과를 PSRAM에 저장합니다. 말하는 동안 버튼 위로 진행 표시줄이 움직입니다. 2초 분량의 음성은 약 64 KB입니다.

  3. 버튼에서 손을 뗍니다. 녹음이 중지됩니다. 보드는 오디오 앞부분에 44바이트 WAV 헤더를 기록합니다. 이 작은 라벨이 원시 샘플을 Azure가 수락할 파일로 바꿔줍니다.

  4. 오디오가 Azure Speech-to-Text로 전송됩니다. 보안 연결을 통해 4 KB 조각으로 업로드되며, 한 줄의 텍스트로 돌아옵니다. 64 KB의 사운드가 약 25바이트의 텍스트가 되었습니다. LED가 호박색으로 바뀝니다.

  5. 질문이 화면에 표시됩니다 파란색 채팅 버블로 나타나므로, 기기가 정확히 무엇을 들었는지 확인할 수 있습니다. 잘못 들은 단어가 대부분의 이상한 답변을 설명하기 때문에 유용합니다.

  6. 텍스트가 DeepSeek로 전송됩니다. 보드는 질문과 함께 답변을 두 개의 짧은 문장으로 유지하라는 상시 지시문을 보냅니다. 모델은 생각합니다. 진정으로 생각합니다. 추론 모델이기 때문입니다. 그리고 답변을 반환합니다.

  7. 답변이 화면에 표시됩니다 회색 버블로 나타납니다. 듣기 전에 읽을 수 있습니다.

  8. 답변이 Azure로 다시 보내져 음성으로 변환됩니다. 보드는 원시 16 kHz PCM 오디오를 요청합니다. 이는 앰프가 원하는 정확한 형식이므로, 이 프로젝트에는 MP3 디코더가 없습니다. 버튼은 이제 GETTING VOICE로 표시되고 LED는 호박색을 유지합니다. 아직 들을 수 있는 것이 없기 때문입니다.

  9. 전체 클립이 단일 샘플이 재생되기 전에 PSRAM으로 다운로드됩니다. 이는 중요합니다. 아래 참고 사항을 확인하세요.

  10. 재생. 오디오가 스피커로 전달되는 순간 LED가 녹색으로 바뀌고 버튼에 SPEAKING이 표시됩니다. 답변이 들립니다.

오디오가 도착하는 대로 재생되지 않고 먼저 다운로드되는 이유. 네트워크에서 스피커로 직접 스트리밍하면 노크 소리처럼 들립니다. 스피커의 버퍼는 약 0.1초만 유지하며, WiFi 전송의 모든 일시 중지가 그보다 길면 버퍼가 비워져 들리는 노크 소리가 발생합니다. 전체 답변을 먼저 PSRAM에 다운로드하면 약 1초의 추가 대기 시간이 들지만 모든 간격이 제거됩니다. 이것이 디스플레이가 SPEAKING이라고 표시하기 전에 GETTING VOICE라고 표시하는 이유이기도 합니다. 이 둘은 실제로 다른 단계입니다.

각 단계에 걸리는 시간

실제 하드웨어에서 간단한 질문에 대해 측정한 결과입니다:

단계

일반적인 시간

녹음

버튼을 누르고 있는 시간만큼

음성-텍스트 변환 (Azure)

약 1.8초

생각 (DeepSeek)

간단한 질문은 약 1.8초, 실제 계산이 필요한 질문은 훨씬 더 오래

음성 가져오기 (Azure)

약 7초 - 가장 큰 비중을 차지

총 시간, 버튼 해제부터 첫 소리까지

약 11초

모든 교환은 자체 타이밍을 시리얼 모니터에 출력하므로, 이 수치를 신뢰하지 않고 직접 측정할 수 있습니다. 더 빠르게 하려면 가장 효과적인 변경은 SYSTEM_PROMPT에서 더 짧은 답변을 요청하는 것입니다. 말할 텍스트가 적을수록 합성하고 다운로드할 오디오도 적어집니다.

보드 자체는 어떤 것도 이해하지 못합니다. 좋은 귀와 좋은 목소리를 가진 전령일 뿐입니다. 지능은 초 단위로 임대됩니다.

이 세 가지 서비스를 사용하는 이유

  • Azure는 음성 입력과 출력을 처리합니다. 음성 합성은 원시 16 kHz PCM을 반환할 수 있으며, 이는 스피커 칩이 원하는 정확한 형식이므로 이 프로젝트에는 MP3 디코더가 없습니다. 음성 인식은 일반 POST로 일반 WAV를 받습니다.

  • DeepSeek는 대화의 두뇌입니다. 빠르고 답변당 1센트의 극히 일부만 비용이 듭니다.

  • OpenAI는 여기서 사용되지 않습니다. 비전 작업을 수행하는 프로젝트 05를 참조하세요.

DeepSeek 모델 이름이 변경되었습니다. 기존 deepseek-chatdeepseek-reasoner 이름은 2026년 7월에 폐지되었습니다. 온라인의 대부분의 튜토리얼은 여전히 이를 사용하며 오류를 반환할 것입니다. 현재 이름은 deepseek-v4-flashdeepseek-v4-pro입니다. 이 프로젝트는 v4-flash를 사용합니다.

추론 모델의 함정

DeepSeek v4-flash는 답변하기 전에 먼저 생각하며, 그 생각 과정도 토큰 한도에 포함됩니다. LLM_MAX_TOKENS를 너무 낮게 설정하면 전체 예산이 추론에 소진되어 답변이 비어 돌아오고, 보드는 아무 말도 하지 않습니다. 그래서 여기서는 400으로 설정되어 있습니다. 어려운 질문은 시간도 더 걸립니다. 간단한 사실은 약 2초 안에 답하지만, 실제 계산이 필요한 질문은 훨씬 오래 걸릴 수 있습니다.

상태 표시등 읽기

색상

의미

파란색

사용자의 말을 듣는 중

주황색

클라우드가 생각 중이거나 음성을 가져오는 중

초록색

말하는 중 - 소리가 시작되는 정확한 순간에 초록색으로 바뀝니다

빨간색

오류 발생 - 시리얼 모니터를 확인하세요

화면 컨트롤

채팅은 전화 대화처럼 스크롤되며, 오래된 메시지는 위로 올라가 사라집니다. CLEAR는 이를 지웁니다. 실제 dBm 수치가 표시되는 WiFi 신호 강도 측정기가 모서리에 있어, 느린 응답이 네트워크 때문인지 서비스 때문인지 궁금할 때 유용합니다.

MaTouch AI ESP32-S3 2.8" 보드 정보

이 페이지의 모든 프로젝트는 Makerfabs의 MaTouch AI ESP32-S3 2.8" TFT ST7789V에서 실행됩니다. 컬러 터치스크린, 3메가픽셀 카메라, 마이크 2개, 실제 스피커 증폭기를 모두 갖춘 올인원 보드로, 8MB PSRAM이 장착된 ESP32-S3로 구동됩니다. 이 조합 덕분에 다른 장치를 연결하지 않고도 단일 보드에서 이러한 AI 프로젝트가 가능합니다.

8MB PSRAM은 여기서 다른 어떤 숫자보다 중요합니다. 이 덕분에 보드가 카메라 프레임, 몇 초 분량의 녹음된 오디오, 또는 base64로 인코딩된 사진을 동시에 메모리에 보관할 수 있습니다. 이 중 어느 것도 ESP32의 일반 RAM에는 들어가지 않습니다.

제조업체 문서: Makerfabs 위키 페이지.

주요 사양

  • 프로세서: ESP32-S3, 듀얼 코어 240MHz, WiFi 2.4GHz + Bluetooth 5.0

  • 메모리: 16MB 플래시, 8MB PSRAM (여기 있는 거의 모든 프로젝트에 필요)

  • 디스플레이: 2.8" IPS, 320×240, ST7789V 드라이버, SPI

  • 터치: GT911 정전식, 동시에 5개 손가락 추적

  • 카메라: OV3660, 3메가픽셀, 최대 2048×1536

  • 마이크: INMP441 I2S 디지털 마이크 2개 (진정한 스테레오 쌍)

  • 스피커: MAX98357A 클래스 D 증폭기, 4Ω에서 3.2W

  • 저장 장치: microSD 카드 슬롯 (SPI 모드)

  • 전원: USB-C, JST 배터리 커넥터, TP4056 충전기, 전원 스위치

  • 기타 내장: WS2812B RGB LED, PCF8563T 배터리 백업 실시간 시계, 그리고 공식 사양에 없는 MAX17048 배터리 연료 게이지

두 USB-C 포트는 동일하지 않습니다. 보드의 스피커는 네이티브 USB 포트와 신호 핀(IO19 및 IO20)을 공유합니다. 이 핀들이 ESP32-S3의 하드와이어링된 USB 데이터 라인이기 때문입니다. 항상 CH340K USB-C 포트(RESET 버튼 옆에 있는 포트)를 통해 업로드하고 전원을 공급하며, USB CDC On BootDisabled로 설정하세요. 잘못된 포트를 사용하면 오디오가 오작동하거나 업로드가 실패할 수 있습니다.

Arduino IDE 설정

이 설정은 중요합니다. 이 보드에서 사람들이 보고하는 대부분의 문제는 이 중 하나가 잘못된 것이며, 코어 버전을 변경하면 재설정되므로 변경 후 다시 확인하세요.

설정

보드

ESP32S3 Dev Module

ESP32 코어 버전

2.0.17

PSRAM

OPI PSRAM

플래시 크기

16MB (128Mb)

파티션 구성

16M Flash (3MB APP/9.9MB FATFS)

USB CDC On Boot

Disabled

업로드 속도

921600

업로드 전 플래시 전체 지우기

Disabled

포트

CH340K USB-C 포트

ESP32 코어 2.0.17을 사용하세요. 3.x는 사용하지 마세요. Espressif는 코어 3에서 온디바이스 얼굴 감지 모델을 제거했기 때문에 얼굴 프로젝트는 거기서 컴파일되지 않습니다. 2.0.17로 고정하면 이 페이지의 모든 프로젝트가 하나의 구성으로 작동합니다. Boards Manager에서 버전 드롭다운을 통해 언제든지 전환할 수 있습니다.

GFX Library for Arduino 버전 1.5.6을 사용하세요. 1.6.x는 사용하지 마세요. 1.6 릴리스는 ESP32 코어 3용으로 제작되어 코어 2.0.17에서 시작 시 멈출 수 있습니다. 업로드 후 화면이 검게 유지되면 이것이 가장 먼저 확인할 사항입니다.

필수 라이브러리

Arduino IDE의 Tools → Manage Libraries를 통해 설치하세요. 버전 번호가 중요합니다. 나열된 버전을 사용하세요.

라이브러리

버전

작성자

GFX Library for Arduino

1.5.6

moononournation

bb_captouch

1.3.1

Larry Bank

ArduinoJson

7.x

Benoit Blanchon

Adafruit NeoPixel

최신 버전 아무거나

Adafruit

secrets.h 설정

WiFi 세부 정보와 API 키는 다운로드에 포함된 secrets.h 파일에 자리 표시자 값과 함께 들어 있습니다. Arduino IDE에서 해당 탭을 열고 자신의 정보로 교체하세요.

WiFi는 2.4 GHz여야 합니다. ESP32-S3는 5 GHz 네트워크를 전혀 감지할 수 없습니다. 라우터가 두 대역을 하나의 이름으로 결합하는 경우(Asus는 이를 Smart Connect라고 부름), 해당 기능을 끄거나 2.4 GHz 대역에 별도의 이름을 지정하여 secrets.h에서 그 이름을 사용하세요.

API 키 얻기

이 프로젝트는 클라우드 AI 서비스와 통신하므로 자체 키가 필요합니다. 이전에 해본 적이 없다면 걱정하지 마세요. 서비스에 계정을 식별하는 비밀번호와 같은 개념입니다. 한 번만 몇 분이면 됩니다.

키는 웹사이트 구독이 아닙니다. 예를 들어 ChatGPT Plus를 결제한다고 해서 API 키가 제공되는 것은 아닙니다. 둘은 별도의 결제 체계를 가진 별도의 제품입니다. 아래에 설명된 개발자 플랫폼에 계정이 필요합니다.

Microsoft Azure Speech - 듣기 및 말하기용

Azure는 음성을 텍스트로 변환하고 답변을 다시 음성으로 변환합니다. 무료 티어는 이 페이지의 모든 기능에 충분히 넉넉합니다.

  1. portal.azure.com으로 이동하여 Microsoft 계정으로 로그인하세요(무료 계정도 괜찮습니다).

  2. Azure를 처음 사용하는 경우 세 가지 선택지를 제공하는 Azure 시작 화면이 표시됩니다. Azure 무료 평가판으로 시작을 선택하세요. Azure에서 무엇이든 만들려면 먼저 구독이 필요합니다. (학생은 대신 Azure for Students를 선택하세요: 동일한 결과이며 카드가 필요 없습니다.) Microsoft Entra ID 관리는 완전히 다른 것이므로 무시하세요.

  3. 리소스 만들기를 클릭하고 Speech를 검색한 다음 Microsoft에서 게시한 Speech service를 선택하세요.

  4. 양식을 작성하세요: 리소스 그룹, 이름은 아무거나 선택하고 가까운 지역(Region)을 선택하세요. 해당 지역을 표시된 그대로 정확히 기록하세요(예: eastus).

  5. 가격 책정 계층(Pricing tier)에서 F0 (Free)를 선택하세요. 이는 매월 약 5시간의 음성-텍스트 변환과 50만 자의 텍스트-음성 변환을 허용합니다.

  6. 검토 + 만들기(Review + create)를 클릭한 다음 만들기(Create)를 클릭하세요. 약 1분 정도 기다린 후 리소스로 이동(Go to resource)을 클릭하세요.

  7. 왼쪽 메뉴에서 키 및 엔드포인트(Keys and Endpoint)를 엽니다. KEY 1위치/지역(Location/Region)을 복사하세요.

이 값을 secrets.hAZURE_SPEECH_KEYAZURE_REGION으로 넣으세요. AZURE_STT_HOST에는 <region>.stt.speech.microsoft.com을 사용하세요. 예를 들어 지역이 eastus라면 eastus.stt.speech.microsoft.com입니다.

신용카드에 관하여. Azure 무료 평가판은 신원 확인을 위해 카드를 요구합니다. 요금이 청구되지는 않습니다. 30일 동안 $200의 크레딧을 받으며, 이후 계정은 종량제(Pay-As-You-Go)로 전환됩니다. 그러나 F0 Speech 티어는 계속 무료이며, 이 프로젝트의 모든 기능은 그 범위 안에 충분히 포함됩니다. 카드를 전혀 제공하고 싶지 않고 학생이라면 Azure for Students 옵션을 통해 카드 없이 크레딧을 받을 수 있습니다.

반드시 "Speech service" 리소스여야 합니다. Translator, Language 또는 일반 Cognitive Services 리소스의 키는 동일해 보이고 완벽하게 유효해 보이지만, 모든 음성 요청에서 오류 401이 반환됩니다. 테스트 중에 이 문제로 1시간을 낭비했습니다. 키가 맞아 보이는데 음성이 401 오류로 실패하면 어떤 종류의 리소스를 만들었는지 확인하세요.

DeepSeek - 사고 부분

DeepSeek는 질문에 실제로 답변하는 언어 모델입니다. 비용이 저렴합니다. 몇 달러의 크레딧으로 수천 개의 답변을 처리할 수 있습니다.

  1. platform.deepseek.com으로 이동하여 계정을 만드세요.

  2. 메뉴에서 API keys를 열고 Create new API key를 클릭하세요.

  3. 즉시 복사하세요. 키는 한 번만 표시되며 다시는 표시되지 않습니다. 분실하면 해당 키를 삭제하고 새로 만드세요.

  4. Top up에서 소액의 크레딧을 추가하세요. 무료 티어는 없지만, 이 사용량에서는 가장 작은 충전으로도 매우 오래 사용할 수 있습니다.

키를 secrets.hDEEPSEEK_KEY로 넣으세요. 키는 sk-로 시작합니다.

모델 이름은 2026년 7월에 변경되었습니다. 기존의 deepseek-chatdeepseek-reasoner는 폐지되어, 온라인에서 찾을 수 있는 대부분의 튜토리얼은 400 오류로 실패합니다. 이 프로젝트에서 이미 설정된 deepseek-v4-flash를 사용하세요.

실행 비용

매우 적지만 무료는 아닙니다. 프로젝트를 실행 상태로 두기 전에 대략적인 지출 비용을 알아야 합니다.

서비스

대략적인 비용

Azure Speech

무료 티어로 월 약 5시간 듣기와 0.5 M자 말하기가 포함됩니다

DeepSeek

답변당 1센트의 일부 - 몇 달러로 수천 개의 답변

OpenAI vision

모델에 따라 사진당 약 1~2센트

가격은 변동되므로 견적이 아닌 참고용으로만 여기세요. 이 모든 서비스에는 지출 내역을 확인할 수 있는 사용량 페이지가 있으며, 모두 지출 한도를 설정할 수 있습니다. 첫날부터 설정해 두는 것이 좋습니다.

키를 비공개로 유지하세요. 키를 가진 사람은 누구나 자금을 사용할 수 있습니다. 키를 동영상, 스크린샷, 포럼 게시물 또는 공개 코드 저장소에 올리지 마십시오. 키가 노출된 적이 있다면 공급업체 웹사이트에서 해당 키를 삭제하고 새 키를 만드십시오. 몇 초면 되며, 이것이 유일한 실제 해결책입니다.

문제 해결

증상

원인 및 해결 방법

화면이 검게 유지됨

잘못된 GFX 라이브러리 버전(1.5.6 사용) 또는 잘못된 보드 설정.

PSRAM 할당 실패 또는 카메라 오류 0xffffffff

도구 → PSRAMOPI PSRAM으로 설정되지 않음.

업로드되지 않음 / COM 포트 없음

잘못된 USB-C 포트 또는 CH340 드라이버가 설치되지 않음.

카메라 오류 후 복구되지 않음

카메라의 리셋 라인이 보드의 RESET 버튼에 연결되어 있어 소프트웨어로 재시작할 수 없음. RESET을 누르십시오. 그래도 실패하면 카메라 리본 케이블을 다시 장착하십시오.

코드 다운로드

이 프로젝트의 전체 Arduino 스케치와 pins.h 및 기타 필요한 모든 파일은 무료로 다운로드할 수 있습니다.

04_Voice_Assistant 다운로드

압축을 풀고 Arduino IDE에서 .ino 파일을 열고 위 설정을 확인한 후 CH340K USB-C 포트를 통해 업로드하십시오.

884-Arduin code for MaTouch AI ESP32S3 2.8in AI Camera: AI voice assistant
언어: C++
/*
 * ===========================================================================
 *  04_Voice_Assistant  —  MaTouch AI ESP32-S3 2.8" TFT ST7789V
 * ===========================================================================
 *
 ----------
 *  ROBOJAX.COM  -  MaTouch AI ESP32-S3 2.8" project series
 *
 *    WATCH THE VIDEO
 *        https://youtu.be/6AL3g3tC_Hk
 *
 *    WRITTEN TUTORIALS - every project, with photos and full explanation
 *        Camera and touchscreen.... https://robojax.com/RTJ849
 *        Offline face recognition.. https://robojax.com/RTJ850
 *        AI voice assistant........ https://robojax.com/RTJ851
 *        AI vision................. https://robojax.com/RTJ852
 *
 *    GET THE BOARD - SAVE $5 with coupon code:  Robojax_Makerfab
 *        https://www.makerfabs.com/matouch-ai-esp32s3-2-8-tft-st7789v.html
 *        (enter the code at checkout)
 *
 *  All of this code is free. If it helped you, a subscribe on YouTube is
 *  the best way to support more of it.
 *  
 *  ---------------------------------------------------------------------------
 *
 *  A complete voice assistant on a $40 board:
 *
 *      hold SPEAK  ->  both INMP441 microphones record you
 *                  ->  Azure Speech turns the audio into text
 *                  ->  DeepSeek v4-flash thinks of an answer
 *                  ->  Azure Speech turns the answer into audio
 *                  ->  the MAX98357 speaker says it out loud
 *
 *  and the whole conversation is drawn as chat bubbles on the touchscreen.
 *
 *  WHY THIS COMBINATION OF SERVICES (each is used where it is best):
 *   - Azure STT accepts a plain WAV in a plain POST with one header. OpenAI's
 *     transcription endpoint wants multipart/form-data - miserable on an MCU.
 *   - Azure TTS can return RAW 16 kHz PCM ("riff-16khz-16bit-mono-pcm"),
 *     which streams straight into the I2S speaker with NO MP3 decoder at all.
 *   - DeepSeek v4-flash is fast and nearly free per reply. NOTE: the old
 *     model names deepseek-chat / deepseek-reasoner were RETIRED in July 2026.
 *     Most tutorials online still use them and are broken. See secrets.h.
 *
 *  A detail the vendor examples get wrong: this board has TWO microphones on
 *  one I2S bus (left + right), but every Makerfabs demo records left-only and
 *  throws one away. This sketch records both and averages them.
 *
 *  ---------------------------------------------------------------------------
 *  *** WHICH USB PORT - THIS MATTERS ***
 *  The speaker shares IO19/IO20 with the NATIVE USB port. Upload and power
 *  through the CH340K UART USB-C port, and set USB CDC On Boot = Disabled.
 *  If you use the wrong port the audio will be garbage or uploads will fail.
 *  ---------------------------------------------------------------------------
 *
 *  FILL IN secrets.h BEFORE FLASHING (WiFi + all three API keys).
 *
 *  BOARD SETTINGS (Tools menu - EVERY line matters, wrong = black screen
 *  or compile errors. These reset when you switch cores - recheck them!)
 *
 *      Board            : ESP32S3 Dev Module
 *      ESP32 core       : 2.0.17
 *      PSRAM            : OPI PSRAM        <-- required, audio buffer lives there
 *      Flash Size       : 16MB (128Mb)
 *      Partition Scheme : 16M Flash (3MB APP/9.9MB FATFS)
 *      USB CDC On Boot  : Disabled         <-- required, see USB note above
 *      Upload Speed     : 921600
 *      Port             : the CH340K USB-C port (the one near RESET)
 *
 *  LIBRARIES
 *      GFX Library for Arduino   v1.5.6   (NOT 1.6.x - that pairs with core 3)
 *      bb_captouch               v1.3.1
 *      ArduinoJson               v7.x
 *      Adafruit NeoPixel         any recent
 *
 *  ---------------------------------------------------------------------------
 *  FUNCTIONS IN THIS SKETCH
 *      led(r,g,b) + LED_* macros   RGB status colours (blue/amber/green/red)
 *      getTouch(&x,&y)      read the touch panel, mapped to screen coordinates
 *      speakButtonHeld()    true while a finger is on the SPEAK button
 *      bubbleLines(t)       how many lines a message wraps to
 *      drawOneBubble(m,y)   draw a single chat bubble
 *      redrawChat()         rebuild the chat area from history, newest at bottom
 *      clearChat()          wipe the chat history (CLEAR button)
 *      chatBubble(t,user)   add a message to history and redraw
 *      drawWifi()           WiFi signal bars + dBm readout
 *      drawBar(label,col)   bottom bar: SPEAK button + CLEAR + WiFi meter
 *      micInit()            I2S input - BOTH INMP441 mics, stereo
 *      spkInit()            I2S output - MAX98357 speaker
 *      recordWhileHeld()    record while SPEAK held, downmix stereo->mono
 *      writeWavHeader(...)  prepend the 44-byte RIFF/WAVE header
 *      dumpWavToSD(...)     save the exact upload to SD (/stt_debug.wav)
 *      readHttpResponse()   read an HTTPS reply, de-chunking it properly
 *      azureSTT(...)        chunked upload of the WAV -> recognised text
 *      deepseekChat(...)    question -> deepseek-v4-flash -> answer text
 *      azureTTSSpeak(text)  answer -> Azure voice -> PSRAM -> speaker
 *      setup() / loop()     boot + WiFi / one conversation turn per press
 *
 *  Robojax.com
 * ===========================================================================
 */

#include <Arduino_GFX_Library.h>
#include <bb_captouch.h>
#include <Adafruit_NeoPixel.h>
#include <ArduinoJson.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <SPI.h>
#include <SD.h>
#include "driver/i2s.h"
#include "pins.h"
#include "secrets.h"

/* --- audio geometry ------------------------------------------------------- */
#define SAMPLE_RATE     16000
#define RECORD_MAX_S    6                       // hard cap on one question
#define WAV_HEADER_LEN  44
#define REC_BUF_BYTES   (SAMPLE_RATE * RECORD_MAX_S * 2)   // 16-bit mono

/* Software gain applied to the recording. The INMP441 capture is quiet at
 * 16-bit depth; if the serial monitor reports "mic peak" under ~10% while you
 * speak normally, raise this (6 -> 10 -> 16). If it reports clipping (100%),
 * lower it. */
#define MIC_GAIN        6

/* 1 = read BOTH microphones (stereo bus) and average them - better SNR.
 * 0 = vendor-style single left mic. Use 0 as a fallback if recordings come
 *     back silent or garbled in stereo mode. */
#define USE_BOTH_MICS   1

/* Status LED brightness, 0-255. The WS2812 runs from the power rail and is
 * uncomfortably bright at full power - 25 is plenty visible on camera. */
#define LED_BRIGHTNESS  25

/* HWSPI (not ESP32SPI): the debug WAV dump writes to the SD card, which
 * shares these pins - both must go through the same SPI driver. */
Arduino_HWSPI *bus = new Arduino_HWSPI(
    TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, TFT_MISO, &SPI, true);
Arduino_GFX *gfx = new Arduino_ST7789(bus, TFT_RES, 1, true);

BBCapTouch bbct;
Adafruit_NeoPixel rgb(RGB_LED_NUM, RGB_LED_PIN, NEO_GRB + NEO_KHZ800);

/* --- big buffers live in PSRAM, allocated once at boot -------------------- */
uint8_t *wav_buf = nullptr;      // WAV_HEADER_LEN + up to REC_BUF_BYTES

/* --- state ---------------------------------------------------------------- */
enum State { ST_IDLE, ST_RECORDING, ST_STT, ST_LLM, ST_TTS, ST_ERROR };
State state = ST_IDLE;

/* --- diagnostics: shown ON SCREEN so the serial monitor is optional -------- */
bool  ok_sd = false;
float g_mic_peak_pct = 0;          // last recording's raw peak, % of full scale
char  g_stt_err[64] = "";          // last STT failure cause, verbatim
char  g_llm_err[64] = "";          // last DeepSeek failure cause, verbatim

/* --- layout --------------------------------------------------------------- */
#define CHAT_H     200              // chat area: y 0..199
#define BAR_Y      202              // button bar below it
#define BTN_SPEAK_X   4
#define BTN_SPEAK_W 160
#define BTN_CLEAR_X 170
#define BTN_CLEAR_W  58
#define WIFI_X      236             // signal indicator, right end of the bar
#define BTN_H        36

/* --- chat history: last 8 messages, redrawn newest-at-bottom like a phone.
 * This is what prevents new text printing over old - the whole area is
 * rebuilt from history on every message, older lines scroll up and out. */
#define CHAT_HISTORY 8
struct ChatMsg { char text[160]; bool from_user; };
ChatMsg chat_hist[CHAT_HISTORY];
int     chat_count = 0;

/* --- LED status colours: visible from across the room --------------------- */
void led(uint8_t r, uint8_t g, uint8_t b) {
  rgb.setPixelColor(0, rgb.Color(r, g, b));
  rgb.show();
}
#define LED_IDLE()    led(0, 0, 0)
#define LED_LISTEN()  led(0, 60, 255)     // blue   - recording
#define LED_THINK()   led(255, 120, 0)    // amber  - waiting on the cloud
#define LED_SPEAK()   led(0, 255, 40)     // green  - talking
#define LED_ERROR()   led(255, 0, 0)      // red


/* ===========================================================================
 *  Touch
 * =========================================================================== */
bool getTouch(uint16_t *x, uint16_t *y) {
  TOUCHINFO ti;
  if (!bbct.getSamples(&ti)) return false;
  if (ti.count < 1) return false;
  *x = ti.y[0];
  *y = (ti.x[0] > 240) ? 0 : (240 - ti.x[0]);
  return true;
}

bool speakButtonHeld() {
  uint16_t x, y;
  if (!getTouch(&x, &y)) return false;
  return (x >= BTN_SPEAK_X && x < BTN_SPEAK_X + BTN_SPEAK_W && y >= BAR_Y);
}


/* ===========================================================================
 *  Chat UI  —  word-wrapped bubbles, user right/blue, assistant left/grey
 * =========================================================================== */
#define CHAT_CHARS 42                          // chars per line at textsize 1

static int bubbleLines(const char *t) {
  int l = ((int)strlen(t) + CHAT_CHARS - 1) / CHAT_CHARS;
  return l < 1 ? 1 : l;
}

void drawOneBubble(const ChatMsg &m, int y) {
  int len = strlen(m.text);
  int lines = bubbleLines(m.text);
  int h = lines * 10 + 8;
  uint16_t bg = m.from_user ? gfx->color565(0, 70, 140) : gfx->color565(50, 50, 55);
  int w = (len > CHAT_CHARS ? CHAT_CHARS : len) * 6 + 10;
  if (w < 30) w = 30;
  int x = m.from_user ? (316 - w) : 4;

  gfx->fillRoundRect(x, y, w, h, 5, bg);
  gfx->setTextSize(1);
  gfx->setTextColor(WHITE);
  for (int i = 0; i < lines; i++) {
    char line[CHAT_CHARS + 1] = {0};
    strncpy(line, m.text + i * CHAT_CHARS, CHAT_CHARS);
    gfx->setCursor(x + 5, y + 5 + i * 10);
    gfx->print(line);
  }
}

/* Rebuild the whole chat area from history: newest message anchored at the
 * bottom, older ones stacked upward until the area is full. */
void redrawChat() {
  gfx->fillRect(0, 0, 320, CHAT_H, BLACK);
  int shown = min(chat_count, CHAT_HISTORY);
  int y = CHAT_H - 2;
  for (int i = 0; i < shown; i++) {
    ChatMsg &m = chat_hist[(chat_count - 1 - i) % CHAT_HISTORY];
    int h = bubbleLines(m.text) * 10 + 8;
    y -= h;
    if (y < 0) break;                          // area full - older ones drop off
    drawOneBubble(m, y);
    y -= 4;
  }
}

void clearChat() {
  chat_count = 0;
  redrawChat();
}

void chatBubble(const char *text, bool from_user) {
  ChatMsg &m = chat_hist[chat_count % CHAT_HISTORY];
  strncpy(m.text, text, sizeof(m.text) - 1);
  m.text[sizeof(m.text) - 1] = 0;
  m.from_user = from_user;
  chat_count++;
  redrawChat();
}

/* WiFi bars + dBm, right end of the button bar. Refreshed from the loop. */
void drawWifi() {
  gfx->fillRect(WIFI_X, BAR_Y, 320 - WIFI_X, BTN_H, BLACK);
  bool up = (WiFi.status() == WL_CONNECTED);
  long rssi = up ? WiFi.RSSI() : -100;
  // -55 dBm or better = full bars; each 10 dB drops one
  int bars = rssi > -55 ? 4 : rssi > -65 ? 3 : rssi > -75 ? 2 : rssi > -85 ? 1 : 0;

  for (int b = 0; b < 4; b++) {
    int bh = 6 + b * 6;                       // heights 6,12,18,24
    uint16_t col = (b < bars) ? GREEN : gfx->color565(60, 60, 60);
    gfx->fillRect(WIFI_X + 2 + b * 8, BAR_Y + 28 - bh, 6, bh, col);
  }
  gfx->setTextSize(1);
  gfx->setCursor(WIFI_X + 38, BAR_Y + 6);
  if (up) {
    gfx->setTextColor(CYAN);
    gfx->printf("%lddBm", rssi);
  } else {
    gfx->setTextColor(RED);
    gfx->print("DOWN");
  }
  gfx->setCursor(WIFI_X + 38, BAR_Y + 18);
  gfx->setTextColor(gfx->color565(120, 120, 120));
  gfx->print("WiFi");
}

void drawBar(const char *label, uint16_t colour) {
  gfx->fillRect(0, BAR_Y, 320, 240 - BAR_Y, BLACK);
  gfx->fillRoundRect(BTN_SPEAK_X, BAR_Y, BTN_SPEAK_W, BTN_H, 6, colour);
  gfx->drawRoundRect(BTN_SPEAK_X, BAR_Y, BTN_SPEAK_W, BTN_H, 6, WHITE);
  gfx->setTextSize(2);
  gfx->setTextColor(WHITE);
  gfx->setCursor(BTN_SPEAK_X + 10, BAR_Y + 10);
  gfx->print(label);

  // CLEAR wipes the chat history
  gfx->fillRoundRect(BTN_CLEAR_X, BAR_Y, BTN_CLEAR_W, BTN_H, 6, gfx->color565(110, 35, 35));
  gfx->drawRoundRect(BTN_CLEAR_X, BAR_Y, BTN_CLEAR_W, BTN_H, 6, WHITE);
  gfx->setTextSize(1);
  gfx->setTextColor(WHITE);
  gfx->setCursor(BTN_CLEAR_X + 14, BAR_Y + 15);
  gfx->print("CLEAR");

  drawWifi();
}


/* ===========================================================================
 *  I2S  —  microphones on port 0, speaker on port 1. Separate hardware
 *  ports, so recording and playback can never fight over a bus.
 * =========================================================================== */
void micInit() {
  i2s_config_t cfg = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    /* BOTH channels - this is the two-microphone fix. The vendor examples
     * use ONLY_LEFT here and waste the second microphone. USE_BOTH_MICS 0
     * falls back to the vendor-proven single-mic configuration. */
#if USE_BOTH_MICS
    .channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT,
#else
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
#endif
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = 256,
    .use_apll = false,
    .tx_desc_auto_clear = false,
    .fixed_mclk = 0
  };
  i2s_pin_config_t pins = {
    .mck_io_num = I2S_PIN_NO_CHANGE,
    .bck_io_num = I2S_MIC_SCK,
    .ws_io_num = I2S_MIC_WS,
    .data_out_num = I2S_PIN_NO_CHANGE,
    .data_in_num = I2S_MIC_SD
  };
  i2s_driver_install(I2S_MIC_PORT, &cfg, 0, NULL);
  i2s_set_pin(I2S_MIC_PORT, &pins);
}

void spkInit() {
  i2s_config_t cfg = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,   // mono - the amp downmixes anyway
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = 256,
    .use_apll = false,
    .tx_desc_auto_clear = true,
    .fixed_mclk = 0
  };
  i2s_pin_config_t pins = {
    .mck_io_num = I2S_PIN_NO_CHANGE,
    .bck_io_num = I2S_SPK_BCLK,
    .ws_io_num = I2S_SPK_LRC,
    .data_out_num = I2S_SPK_DOUT,
    .data_in_num = I2S_PIN_NO_CHANGE
  };
  i2s_driver_install(I2S_SPK_PORT, &cfg, 0, NULL);
  i2s_set_pin(I2S_SPK_PORT, &pins);
  i2s_zero_dma_buffer(I2S_SPK_PORT);
}


/* ===========================================================================
 *  Recording  —  runs while the SPEAK button is held (up to RECORD_MAX_S).
 *  Reads stereo pairs, averages L+R into one mono stream, applies a little
 *  software gain, and fills wav_buf after the 44-byte header slot.
 *  Returns the number of audio bytes recorded.
 * =========================================================================== */
size_t recordWhileHeld() {
  int16_t *mono = (int16_t *)(wav_buf + WAV_HEADER_LEN);
  size_t   mono_samples = 0;
  const size_t max_samples = SAMPLE_RATE * RECORD_MAX_S;

  int16_t chunk[512];
  uint32_t last_touch_ok = millis();
  int32_t  peak = 0;                        // loudest raw sample - mic health check

  i2s_zero_dma_buffer(I2S_MIC_PORT);

  while (mono_samples < max_samples) {
    size_t got = 0;
    i2s_read(I2S_MIC_PORT, chunk, sizeof(chunk), &got, 80 / portTICK_PERIOD_MS);

#if USE_BOTH_MICS
    size_t n = got / 4;                     // 4 bytes = one L+R pair
    for (size_t i = 0; i < n && mono_samples < max_samples; i++) {
      int32_t raw = ((int32_t)chunk[i * 2] + (int32_t)chunk[i * 2 + 1]) / 2;
#else
    size_t n = got / 2;                     // 2 bytes = one mono sample
    for (size_t i = 0; i < n && mono_samples < max_samples; i++) {
      int32_t raw = chunk[i];
#endif
      if (abs(raw) > peak) peak = abs(raw);
      int32_t mixed = raw * MIC_GAIN;
      if (mixed >  32767) mixed =  32767;
      if (mixed < -32768) mixed = -32768;
      mono[mono_samples++] = (int16_t)mixed;
    }

    /* The GT911 is polled between I2S reads. A 250 ms grace period stops a
     * momentary missed touch sample from cutting the recording short. */
    if (speakButtonHeld()) last_touch_ok = millis();
    else if (millis() - last_touch_ok > 250) break;

    // live progress on the button
    static uint32_t last_draw = 0;
    if (millis() - last_draw > 200) {
      last_draw = millis();
      gfx->fillRect(BTN_SPEAK_X + 2, BAR_Y + BTN_H - 6,
                    (int)((BTN_SPEAK_W - 4) * mono_samples / max_samples), 4, WHITE);
    }
  }

  /* Mic health line: peak as % of full scale BEFORE gain.
   *   0%       = the mic is not being read at all (config/pin problem)
   *   under 3% = too quiet - speak closer or raise MIC_GAIN
   *   3-40%    = healthy speech level
   */
  g_mic_peak_pct = peak * 100.0 / 32768.0;
  Serial.printf("mic peak: %.1f%% of full scale (gain x%d applied%s)\n",
                g_mic_peak_pct, MIC_GAIN,
                peak == 0 ? " - MIC IS SILENT, check USE_BOTH_MICS" : "");

  return mono_samples * 2;
}

/* Dump the exact WAV we are about to POST onto the SD card, so it can be
 * played on a PC - you hear exactly what Azure hears. Overwritten each time. */
void dumpWavToSD(size_t audio_bytes) {
  if (!ok_sd) return;
  SD.remove("/stt_debug.wav");
  File f = SD.open("/stt_debug.wav", FILE_WRITE);
  if (!f) return;
  f.write(wav_buf, WAV_HEADER_LEN + audio_bytes);
  f.close();
  Serial.println("debug copy saved to SD as /stt_debug.wav");
}

/* Standard 44-byte RIFF/WAVE header for 16 kHz 16-bit mono PCM. */
void writeWavHeader(uint8_t *h, uint32_t data_bytes) {
  uint32_t file_len = data_bytes + 36;
  uint32_t byte_rate = SAMPLE_RATE * 2;
  memcpy(h, "RIFF", 4);           memcpy(h + 4, &file_len, 4);
  memcpy(h + 8, "WAVEfmt ", 8);
  uint32_t fmt_len = 16;          memcpy(h + 16, &fmt_len, 4);
  uint16_t fmt = 1, ch = 1;       memcpy(h + 20, &fmt, 2);   memcpy(h + 22, &ch, 2);
  uint32_t rate = SAMPLE_RATE;    memcpy(h + 24, &rate, 4);  memcpy(h + 28, &byte_rate, 4);
  uint16_t align = 2, bits = 16;  memcpy(h + 32, &align, 2); memcpy(h + 34, &bits, 2);
  memcpy(h + 36, "data", 4);      memcpy(h + 40, &data_bytes, 4);
}


/* ===========================================================================
 *  HTTP response reader  —  shared by all three cloud calls.
 *  Returns the status code and fills body_out. Handles chunked transfer
 *  encoding PROPERLY: the chunk-size markers must be stripped, or they end
 *  up embedded inside the JSON body and the parse fails on long replies.
 * =========================================================================== */
/* Block until the connection has data (or the budget runs out). Returns false
 * on timeout / closed-and-empty. Every read below goes through this, because
 * a reasoning model can think for many seconds between the response headers
 * and the first byte of the body - and a bare read() would just time out. */
static bool waitData(WiFiClientSecure &c, uint32_t ms) {
  uint32_t t0 = millis();
  while (!c.available()) {
    if (!c.connected()) return false;
    if (millis() - t0 > ms) return false;
    delay(10);
  }
  return true;
}

static int readHttpResponse(WiFiClientSecure &client, String &body_out, uint32_t idle_ms) {
  body_out = "";

  if (!waitData(client, idle_ms)) { Serial.println("HTTP: no response at all"); return 0; }
  String status_line = client.readStringUntil('\n');
  int code = 0;
  sscanf(status_line.c_str(), "HTTP/%*s %d", &code);

  bool chunked = false;
  while (waitData(client, idle_ms)) {
    String h = client.readStringUntil('\n');
    if (h == "\r" || h.length() <= 1) break;         // blank line = end of headers
    h.toLowerCase();
    if (h.startsWith("transfer-encoding:") && h.indexOf("chunked") >= 0) chunked = true;
  }

  if (chunked) {
    int blanks = 0;
    while (true) {
      /* The chunk-size line may not arrive for a long time while the model
       * reasons. Waiting here - instead of letting read() time out - is the
       * whole fix: a timed-out read looks exactly like "0" (final chunk),
       * which silently truncated the body to nothing. */
      if (!waitData(client, idle_ms)) {
        Serial.println("HTTP: timed out waiting for the next chunk");
        break;
      }
      String szline = client.readStringUntil('\n');
      szline.trim();
      if (szline.length() == 0) {                    // stray blank line
        if (++blanks > 4) break;
        continue;
      }
      blanks = 0;

      long sz = strtol(szline.c_str(), NULL, 16);
      if (sz <= 0) break;                            // genuine final chunk

      long got = 0;
      while (got < sz) {
        if (!waitData(client, idle_ms)) break;
        while (client.available() && got < sz) { body_out += (char)client.read(); got++; }
      }
      if (waitData(client, 3000)) client.readStringUntil('\n');   // CRLF after chunk
      if (got < sz) { Serial.println("HTTP: short chunk"); break; }
    }
  } else {
    while (waitData(client, idle_ms))
      while (client.available()) body_out += (char)client.read();
  }
  return code;
}


/* ===========================================================================
 *  CLOUD CALL 1  —  Azure speech-to-text
 *  One POST, one header, plain WAV body. This is why Azure does the ears.
 * =========================================================================== */
bool azureSTT(size_t audio_bytes, String &text_out) {
  /* HTTPClient's one-shot POST fails on bodies this large (it attempts one
   * giant TLS write and dies with error -3 SEND_PAYLOAD_FAILED). So this
   * function speaks HTTP directly and streams the WAV up in 4 KB chunks -
   * reliable, and if it ever stalls we know the exact byte it stopped at. */
  WiFiClientSecure client;
  client.setInsecure();                    // no cert bundle on-device; see notes
  client.setTimeout(15);                   // seconds, for reads

  writeWavHeader(wav_buf, audio_bytes);
  dumpWavToSD(audio_bytes);                // PC-playable copy of what we send
  size_t total = WAV_HEADER_LEN + audio_bytes;

  if (!client.connect(AZURE_STT_HOST, 443)) {
    snprintf(g_stt_err, sizeof(g_stt_err), "TLS connect failed");
    Serial.println("STT: TLS connect failed");
    return false;
  }

  /* Two valid host forms use DIFFERENT URL paths - detect which one is in
   * secrets.h:  <resource>.cognitiveservices.azure.com -> /stt/speech/...
   *             <region>.stt.speech.microsoft.com      -> /speech/...     */
  bool custom_subdomain = (strstr(AZURE_STT_HOST, ".cognitiveservices.azure.com") != NULL);
  String req = String("POST ") + (custom_subdomain ? "/stt" : "") +
               "/speech/recognition/conversation/cognitiveservices/v1"
               "?language=" AZURE_STT_LANG "&format=simple HTTP/1.1\r\n"
               "Host: " AZURE_STT_HOST "\r\n"
               "Ocp-Apim-Subscription-Key: " AZURE_SPEECH_KEY "\r\n"
               "Content-Type: audio/wav; codecs=audio/pcm; samplerate=16000\r\n"
               "Accept: application/json\r\n"
               "Connection: close\r\n"
               "Content-Length: " + String(total) + "\r\n\r\n";
  client.print(req);

  /* body, 4 KB at a time */
  size_t sent = 0;
  while (sent < total) {
    size_t n = min((size_t)4096, total - sent);
    size_t w = client.write(wav_buf + sent, n);
    if (w == 0) {
      delay(50);                           // brief stall - retry once
      w = client.write(wav_buf + sent, n);
      if (w == 0) {
        snprintf(g_stt_err, sizeof(g_stt_err), "upload stalled at %uKB",
                 (unsigned)(sent / 1024));
        Serial.printf("STT: upload stalled at %u/%u bytes\n",
                      (unsigned)sent, (unsigned)total);
        client.stop();
        return false;
      }
    }
    sent += w;
    yield();
  }
  Serial.printf("STT: uploaded %u bytes\n", (unsigned)sent);

  /* read the reply with proper de-chunking */
  String resp;
  int code = readHttpResponse(client, resp, 10000);
  client.stop();

  if (code != 200) {
    snprintf(g_stt_err, sizeof(g_stt_err), "HTTP %d", code);
    Serial.printf("STT HTTP %d: %s\n", code, resp.c_str());
    return false;
  }

  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, resp);
  if (err) {
    snprintf(g_stt_err, sizeof(g_stt_err), "bad JSON reply");
    Serial.printf("STT parse error, raw response: %s\n", resp.c_str());
    return false;
  }

  const char *status = doc["RecognitionStatus"];
  if (!status || strcmp(status, "Success") != 0) {
    /* The status names the exact failure:
     *   InitialSilenceTimeout = Azure heard silence (mic level too low)
     *   NoMatch               = heard sound but no recognisable words
     *   BabbleTimeout         = heard only noise                       */
    snprintf(g_stt_err, sizeof(g_stt_err), "%s", status ? status : "no status");
    Serial.printf("STT status: %s\nraw: %s\n", status ? status : "null", resp.c_str());
    return false;
  }
  text_out = doc["DisplayText"].as<String>();
  if (text_out.length() == 0) snprintf(g_stt_err, sizeof(g_stt_err), "empty text");
  return text_out.length() > 0;
}


/* ===========================================================================
 *  CLOUD CALL 2  —  DeepSeek chat completion
 *  OpenAI-compatible format. Model name is deepseek-v4-flash - the old
 *  deepseek-chat name is dead, see secrets.h.
 * =========================================================================== */
bool deepseekChat(const String &question, String &answer_out) {
  /* Manual HTTP, same as azureSTT: HTTPClient truncates larger TLS response
   * bodies (long answers + the model's hidden reasoning), which shows up as
   * "bad JSON reply". Reading until the server closes the connection is
   * reliable regardless of reply length. */
  JsonDocument req;
  req["model"] = DEEPSEEK_MODEL;
  req["max_tokens"] = LLM_MAX_TOKENS;
  JsonArray msgs = req["messages"].to<JsonArray>();
  JsonObject sys = msgs.add<JsonObject>();
  sys["role"] = "system";  sys["content"] = SYSTEM_PROMPT;
  JsonObject usr = msgs.add<JsonObject>();
  usr["role"] = "user";    usr["content"] = question;

  String body;
  serializeJson(req, body);

  WiFiClientSecure client;
  client.setInsecure();
  /* 60 s: deepseek-v4-flash is a REASONING model. Easy questions answer in
   * ~2 s, but anything that needs actual working-out (an Ohm's law problem,
   * say) can think for 10-30 s before sending a single byte. */
  client.setTimeout(60);

  if (!client.connect(DEEPSEEK_HOST, 443)) {
    snprintf(g_llm_err, sizeof(g_llm_err), "TLS connect failed");
    Serial.println("LLM: TLS connect failed");
    return false;
  }

  client.print(String("POST /chat/completions HTTP/1.1\r\n"
               "Host: " DEEPSEEK_HOST "\r\n"
               "Authorization: Bearer " DEEPSEEK_KEY "\r\n"
               "Content-Type: application/json\r\n"
               "Connection: close\r\n"
               "Content-Length: ") + String(body.length()) + "\r\n\r\n");
  client.print(body);

  /* read the reply with proper de-chunking; generous window - long
   * questions make the model think for a while before it responds */
  String resp;
  int code = readHttpResponse(client, resp, 60000);
  client.stop();

  if (code != 200) {
    snprintf(g_llm_err, sizeof(g_llm_err), "HTTP %d", code);
    Serial.printf("LLM HTTP %d: %s\n", code, resp.c_str());
    return false;
  }

  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, resp);
  if (err) {
    snprintf(g_llm_err, sizeof(g_llm_err), "bad JSON reply");
    Serial.printf("LLM parse error, raw: %s\n", resp.c_str());
    return false;
  }

  const char *content = doc["choices"][0]["message"]["content"];
  const char *finish  = doc["choices"][0]["finish_reason"];
  if (!content || !content[0]) {
    /* v4-flash is a reasoning model: if finish_reason is "length", the whole
     * token budget went to internal reasoning - raise LLM_MAX_TOKENS. */
    if (finish && strcmp(finish, "length") == 0)
      snprintf(g_llm_err, sizeof(g_llm_err), "empty - raise LLM_MAX_TOKENS");
    else
      snprintf(g_llm_err, sizeof(g_llm_err), "empty content");
    Serial.printf("LLM empty content, raw: %s\n", resp.c_str());
    return false;
  }
  answer_out = String(content);
  answer_out.trim();
  if (answer_out.length() == 0) snprintf(g_llm_err, sizeof(g_llm_err), "blank answer");
  return answer_out.length() > 0;
}


/* ===========================================================================
 *  CLOUD CALL 3  —  Azure text-to-speech, streamed straight to the speaker
 *  We ask for riff-16khz-16bit-mono-pcm: a WAV whose payload is exactly what
 *  the I2S peripheral eats. Skip the 44-byte header, forward the rest.
 *  No MP3 decoder, no audio library, no buffering the whole reply.
 * =========================================================================== */
/* Read exactly n bytes from a client (or until timeout). */
static size_t readExact(WiFiClientSecure &c, uint8_t *dst, size_t n) {
  size_t got = 0;
  uint32_t t0 = millis();
  while (got < n && millis() - t0 < 10000) {
    int r = c.read(dst + got, n - got);
    if (r > 0) { got += r; t0 = millis(); }
    else if (!c.connected() && !c.available()) break;
    else delay(2);
  }
  return got;
}

bool azureTTSSpeak(const String &text) {
  // Escape the XML special characters for the SSML body
  String safe = text;
  safe.replace("&", "&amp;");
  safe.replace("<", "&lt;");
  safe.replace(">", "&gt;");

  String ssml = "<speak version='1.0' xml:lang='" AZURE_TTS_LANG "'>"
                "<voice name='" AZURE_TTS_VOICE "'>" + safe + "</voice></speak>";

  /* Manual HTTP like the other two cloud calls - and for a hard reason:
   * Azure sends this audio with CHUNKED transfer encoding, and HTTPClient's
   * raw stream hands over the chunk framing (ASCII "2000\r\n" lines) mixed
   * into the PCM. Played as sound, every chunk boundary is an audible KNOCK.
   * Here we parse the framing properly and keep only clean audio bytes. */
  WiFiClientSecure client;
  client.setInsecure();
  client.setTimeout(20);

  const char *host = AZURE_REGION ".tts.speech.microsoft.com";
  if (!client.connect(host, 443)) {
    Serial.println("TTS: TLS connect failed");
    return false;
  }

  client.print(String("POST /cognitiveservices/v1 HTTP/1.1\r\n"
               "Host: ") + host + "\r\n"
               "Ocp-Apim-Subscription-Key: " AZURE_SPEECH_KEY "\r\n"
               "Content-Type: application/ssml+xml\r\n"
               "X-Microsoft-OutputFormat: riff-16khz-16bit-mono-pcm\r\n"
               "User-Agent: MaTouchRobojax\r\n"
               "Connection: close\r\n"
               "Content-Length: " + String(ssml.length()) + "\r\n\r\n");
  client.print(ssml);

  /* status + headers; note whether the body is chunked */
  String status_line = client.readStringUntil('\n');
  int code = 0;
  sscanf(status_line.c_str(), "HTTP/%*s %d", &code);
  bool chunked = false;
  long content_len = -1;
  while (client.connected() || client.available()) {
    String h = client.readStringUntil('\n');
    if (h == "\r" || h.length() <= 1) break;
    h.toLowerCase();
    if (h.startsWith("transfer-encoding:") && h.indexOf("chunked") >= 0) chunked = true;
    if (h.startsWith("content-length:")) content_len = h.substring(15).toInt();
  }
  if (code != 200) {
    Serial.printf("TTS HTTP %d\n", code);
    client.stop();
    return false;
  }

  const size_t AUDIO_CAP = 1200 * 1024;    // ~37 s of speech
  uint8_t *audio = (uint8_t *)ps_malloc(AUDIO_CAP);
  if (!audio) { client.stop(); return false; }
  size_t alen = 0;

  if (chunked) {
    /* chunked: <hex size>\r\n <bytes> \r\n ... 0\r\n\r\n */
    while (true) {
      String szline = client.readStringUntil('\n');
      long sz = strtol(szline.c_str(), NULL, 16);
      if (sz <= 0) break;
      if (alen + sz > AUDIO_CAP) break;
      size_t got = readExact(client, audio + alen, sz);
      alen += got;
      client.readStringUntil('\n');        // trailing CRLF after each chunk
      if (got < (size_t)sz) break;
    }
  } else if (content_len > 0) {
    alen = readExact(client, audio, min((size_t)content_len, AUDIO_CAP));
  } else {
    /* no framing info: read until the server closes */
    uint32_t idle = millis();
    while ((client.connected() || client.available()) && millis() - idle < 5000) {
      int r = client.read(audio + alen, min((size_t)2048, AUDIO_CAP - alen));
      if (r > 0) { alen += r; idle = millis(); }
      else delay(5);
    }
  }
  client.stop();
  Serial.printf("TTS: %u KB clean audio (%s), playing\n",
                (unsigned)(alen / 1024), chunked ? "de-chunked" : "plain");

  bool ok = (alen > WAV_HEADER_LEN);
  if (ok) {
    /* NOW the audio actually starts - this is the honest moment to go green */
    LED_SPEAK();
    drawBar("SPEAKING...", gfx->color565(0, 130, 40));

    /* skip the RIFF header; a silence pre-roll softens the amp wake-up pop */
    static const uint8_t lead_in[640] = {0};             // 20 ms of silence
    size_t w = 0;
    i2s_write(I2S_SPK_PORT, lead_in, sizeof(lead_in), &w, portMAX_DELAY);
    i2s_write(I2S_SPK_PORT, audio + WAV_HEADER_LEN, alen - WAV_HEADER_LEN, &w, portMAX_DELAY);
    i2s_write(I2S_SPK_PORT, lead_in, sizeof(lead_in), &w, portMAX_DELAY);
  }
  free(audio);

  // let the DMA buffers drain so the last word is not cut off
  delay(150);
  i2s_zero_dma_buffer(I2S_SPK_PORT);
  return ok;
}


/* ===========================================================================
 *  SETUP
 * =========================================================================== */
void setup() {
  Serial.begin(115200);
  delay(400);
  Serial.println("\n=== 04 Voice Assistant  |  Robojax.com ===");
  Serial.println("Mics -> Azure STT -> DeepSeek -> Azure TTS -> speaker");

  pinMode(TFT_BLK, OUTPUT);
  digitalWrite(TFT_BLK, LOW);
  pinMode(SD_CS, OUTPUT);
  digitalWrite(SD_CS, HIGH);

  // one shared SPI bus for TFT + SD (started before either device)
  SPI.begin(TFT_SCLK, TFT_MISO, TFT_MOSI);

  gfx->begin();
  gfx->fillScreen(BLACK);
  digitalWrite(TFT_BLK, HIGH);

  // SD is optional here - it only stores the /stt_debug.wav diagnostic copy
  ok_sd = SD.begin(SD_CS, SPI, 20000000);
  digitalWrite(SD_CS, HIGH);
  Serial.println(ok_sd ? "SD ok - will save /stt_debug.wav after each recording"
                       : "SD not found - debug WAV dump disabled (not fatal)");

  bbct.init(TOUCH_SDA, TOUCH_SCL, TOUCH_RST, TOUCH_INT);
  delay(50);

  rgb.begin();
  rgb.setBrightness(LED_BRIGHTNESS);
  LED_IDLE();

  /* One recording buffer for the whole session, in PSRAM. This is the 8 MB
   * that makes the board worth buying. */
  wav_buf = (uint8_t *)ps_malloc(WAV_HEADER_LEN + REC_BUF_BYTES);
  if (!wav_buf) {
    gfx->setTextColor(RED);
    gfx->setTextSize(2);
    gfx->setCursor(10, 100);
    gfx->print("PSRAM alloc failed!");
    gfx->setTextSize(1);
    gfx->setCursor(10, 130);
    gfx->print("Tools > PSRAM > OPI PSRAM must be set.");
    while (1) delay(1000);
  }

  micInit();
  spkInit();

  gfx->setTextSize(1);
  gfx->setTextColor(YELLOW);
  gfx->setCursor(4, 4);
  gfx->printf("Connecting to %s ...", WIFI_SSID);
  Serial.printf("Connecting to %s ", WIFI_SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) {
    delay(300);
    Serial.print(".");
  }
  Serial.println();

  clearChat();
  if (WiFi.status() == WL_CONNECTED) {
    Serial.printf("Connected, IP %s\n", WiFi.localIP().toString().c_str());
    chatBubble("Hold SPEAK and ask me anything.", false);
  } else {
    chatBubble("WiFi failed. Remember: the ESP32 is 2.4GHz only. Check secrets.h, then press RESET.", false);
    LED_ERROR();
  }
  drawBar("HOLD+TALK", gfx->color565(0, 90, 160));
}


/* ===========================================================================
 *  LOOP  —  one full conversation turn per button press
 * =========================================================================== */
void loop() {
  /* CLEAR button: edge-detected so one tap wipes once. Reading the panel
   * twice per loop (here and in speakButtonHeld) is fine - the GT911 just
   * reports its current state. */
  static bool tap_latch = false;
  static uint8_t tap_release = 0;
  if (state == ST_IDLE) {
    uint16_t cx, cy;
    if (getTouch(&cx, &cy)) {
      tap_release = 0;
      if (!tap_latch) {
        tap_latch = true;
        if (cx >= BTN_CLEAR_X && cx < BTN_CLEAR_X + BTN_CLEAR_W && cy >= BAR_Y) {
          clearChat();
          chatBubble("Hold SPEAK and ask me anything.", false);
        }
      }
    } else if (tap_latch && ++tap_release >= 4) {
      tap_latch = false;
      tap_release = 0;
    }

    /* live WiFi signal indicator, refreshed every 2 s while idle */
    static uint32_t last_wifi = 0;
    if (millis() - last_wifi > 2000) {
      last_wifi = millis();
      drawWifi();
    }
  }

  if (state == ST_IDLE && speakButtonHeld()) {

    /* ---- record ---- */
    state = ST_RECORDING;
    LED_LISTEN();
    drawBar("LISTENING...", gfx->color565(0, 60, 200));
    uint32_t t_rec = millis();
    size_t audio_bytes = recordWhileHeld();
    t_rec = millis() - t_rec;
    Serial.printf("Recorded %u bytes (%.1f s)\n", (unsigned)audio_bytes, audio_bytes / 32000.0);

    if (audio_bytes < SAMPLE_RATE / 2) {   // under a quarter second - a tap
      drawBar("HOLD+TALK", gfx->color565(0, 90, 160));
      LED_IDLE();
      state = ST_IDLE;
      return;
    }

    /* ---- speech to text ---- */
    state = ST_STT;
    LED_THINK();
    drawBar("HEARD YOU...", gfx->color565(150, 90, 0));
    uint32_t t_stt = millis();
    String question;
    if (!azureSTT(audio_bytes, question)) {
      /* Show the REAL cause on screen - no serial monitor needed. */
      char diag[96];
      snprintf(diag, sizeof(diag), "STT failed: %s | mic peak %.1f%%%s",
               g_stt_err, g_mic_peak_pct,
               ok_sd ? " | saved /stt_debug.wav" : "");
      chatBubble(diag, false);
      drawBar("HOLD+TALK", gfx->color565(0, 90, 160));
      LED_IDLE();
      state = ST_IDLE;
      return;
    }
    t_stt = millis() - t_stt;
    chatBubble(question.c_str(), true);
    Serial.printf("STT (%lu ms): %s\n", (unsigned long)t_stt, question.c_str());

    /* ---- think ---- */
    state = ST_LLM;
    drawBar("THINKING...", gfx->color565(150, 90, 0));
    uint32_t t_llm = millis();
    String answer;
    if (!deepseekChat(question, answer)) {
      char diag[96];
      snprintf(diag, sizeof(diag), "DeepSeek failed: %s", g_llm_err);
      chatBubble(diag, false);
      drawBar("HOLD+TALK", gfx->color565(0, 90, 160));
      LED_IDLE();
      state = ST_IDLE;
      return;
    }
    t_llm = millis() - t_llm;
    chatBubble(answer.c_str(), false);
    Serial.printf("LLM (%lu ms): %s\n", (unsigned long)t_llm, answer.c_str());

    /* ---- speak ----
     * Still amber here: the voice has to be synthesised and downloaded first
     * (a few seconds). azureTTSSpeak() itself flips the bar and LED to green
     * at the exact moment audio starts coming out of the speaker. */
    state = ST_TTS;
    LED_THINK();
    drawBar("GETTING VOICE", gfx->color565(150, 90, 0));
    uint32_t t_tts = millis();
    bool spoke = azureTTSSpeak(answer);
    t_tts = millis() - t_tts;

    /* Timing summary on serial - this feeds the "honest numbers" segment. */
    Serial.printf("TIMINGS  rec %.1fs | stt %lums | llm %lums | tts %lums%s\n",
                  audio_bytes / 32000.0, (unsigned long)t_stt,
                  (unsigned long)t_llm, (unsigned long)t_tts,
                  spoke ? "" : "  (TTS FAILED)");

    drawBar("HOLD+TALK", gfx->color565(0, 90, 160));
    LED_IDLE();
    state = ST_IDLE;
  }

  delay(20);
}

파일📁

필수 파일 (.h)

  • secrets.h
    file for Makerfabs MaTouch AI ESP32S3 2.8" TFT Camera module
    secrets.h 0.01 MB

기타 파일

  • pins.h
    pins file for MaTouch AI ESP32S3 2.8" camera LCD touch screen.
    pins.h 0.01 MB

도면

  • MaTouch_AI 2.8“ MaTouch AI ESP32S3 2.8" TFT ST7789V schematic
    The latest MaTouch AI board integrate I2S voice input/I2S speaker/ 3 million camera OV3660/ 320*240 resolution display, with ESP32S3 strong processor& Wifi ability, to make this board a good tool/platform for AI development with ESP32.
    MaTouch_AI 2.8“ SPI TFT ST7789V V1.1.PDF 0.15 MB