LILYGO T-Display S3 + ES9023 — Wi-Fi 오디오 제작 노트

LILYGO T-Display S3 + ES9023 — Wi-Fi 오디오 제작 노트
MAKER NOTES / AUDIO PROJECT / 01

작은 보드에서
음악이 나오기까지LILYGO T-Display S3 + ES9023 Wi-Fi 오디오 제작 노트

결선부터 시작해 사인파, 인터넷 라디오, 화면과 버튼을 차례로 확인한다. 완성된 결과보다 먼저, 무엇을 어떻게 검증할지 기록해 둔다.

테스트 1·2·3 사용자 통과 · 후속 재생 성공 · 보드 정상 동작 확인

실물 사진 6장, 제안 결선도와 컴파일 검증한 테스트 코드 3종을 반영했습니다. 테스트 1은 도레미파솔로 변경 후 성공했고, 테스트 2 Wi-Fi 시험도 통과했습니다. Wi-Fi는 2.4GHz 대역에서만 정상 연결을 확인했습니다. 테스트 3도 사용자 확인으로 통과했으며, 전원 측정값은 별도 미확인입니다. 본문의 초기 Spotify 포트는 컴파일 검증본입니다. 후속 재생 성공 사례와 보드 정상 동작 확인 상태를 글 마지막에 추가했습니다.

00현재 상태부터 분명하게

목표는 LILYGO에서 받은 오디오를 ES9023 DAC로 보내고, 앰프를 거쳐 음악을 듣는 것이다. 기존 제작 가이드는 T-Display S3와 ES9023 V2.1 조합을 전제로 한다. 사진에서 DAC의 TeraDak ES9023 V2.1 표기와 T-Display S3 포장을 확인했다. 실제 구성에서의 재생과 정상 동작은 제작자가 확인했다.

준비와 실제 동작 검증을 구분한 진행표
단계확인할 내용상태
결선 준비모델·핀 실크·전원 경로사진 기록 · 실기 동작 확인
테스트 1도레미파솔 음계 출력 시험으로 변경PASS · 사용자 확인
테스트 2Wi-Fi 시험 — 2.4GHz에서만 정상 연결 확인PASS · 사용자 확인
테스트 3LCD 표시와 버튼 입력PASS · 사용자 확인

01사진으로 확인한 부품

T-Display S3 포장과 DAC, 준비된 점퍼선. 아직 두 보드 사이 결선 완료 사진은 아니다.
사진 1. T-Display S3 포장과 DAC, 준비된 점퍼선. 아직 두 보드 사이 결선 완료 사진은 아니다.
위는 T-PicoC3, 아래는 T-Display S3 포장이다. 이 글의 결선 대상은 아래 T-Display S3이며, 포장 사진만으로 연결된 실물 보드의 리비전을 확정하지 않는다.
사진 2. 위는 T-PicoC3, 아래는 T-Display S3 포장이다. 이 글의 결선 대상은 아래 T-Display S3이며, 포장 사진만으로 연결된 실물 보드의 리비전을 확정하지 않는다.

전체 오디오 구성

그림 1. 기존 가이드의 목표를 정리한 개념도. 실물 사진이나 검증 완료된 결선도가 아니다.

처음부터 모든 기능을 한 번에 붙이지 않는다. 네트워크 없이 소리를 확인하고, 그다음 라디오를 재생한 뒤 화면과 버튼을 시험한다. 어느 단계에서 문제가 생겼는지 구분하기 위한 순서다.

02핀 이름으로 따라가는 결선도

아래는 기존 두 스케치의 GPIO1·GPIO2·GPIO10 설정을 유지한 T-Display S3 전용 제안 결선이다. 공식 핀맵에서 해당 헤더를 확인했다. T-PicoC3나 다른 S3 파생 보드에 이 위치를 그대로 적용하지 않는다.

DAC 부품면. TeraDak ES9023 V2.1 실크와 전원·디지털 입력 커넥터를 확인할 수 있다.
사진 3. DAC 부품면. TeraDak ES9023 V2.1 실크와 전원·디지털 입력 커넥터를 확인할 수 있다.
DAC 뒷면. 이 사진 방향에서 디지털 헤더는 왼쪽부터 MCLK · GND · DATA · LRCK · BCK, 전원은 +5V · GND 순이다. 앞면에서 보면 좌우가 뒤집힌다.
사진 4. DAC 뒷면. 이 사진 방향에서 디지털 헤더는 왼쪽부터 MCLK · GND · DATA · LRCK · BCK, 전원은 +5V · GND 순이다. 앞면에서 보면 좌우가 뒤집힌다.
그림 2. LILYGO와 ES9023 논리 결선도
그림 2. 전원과 MCLK 조건을 명시한 논리 결선도. 실제 배선 완료 또는 통전 검증을 의미하지 않는다. 작은 화면에서는 이미지를 확대해 확인한다.
제안 연결표 — GPIO 번호는 스케치 설정과 일치해야 한다
LILYGODAC확인할 점
GPIO1BCK비트 클록
GPIO2LRCK워드 클록
GPIO10DATA오디오 데이터
GNDGND (I²S)공통 접지
5V / VBUS+5V (전원)측정·공급 능력 확인 후
GNDGND (전원)전원 리턴
연결하지 않음*MCLK온보드 발진기 연결·동작 확인을 전제

실제 헤더 위치를 찾는 기준

실제 헤더 위치와 ES9023 연결 안내
첨부하신 핀맵 위의 실제 패드를 표시했습니다. 보드는 부품면·USB-C 아래 방향이며, 오른쪽 DAC 상자는 기능 표시입니다. DAC의 실제 헤더 순서는 위 뒷면 사진을 따릅니다. 작은 화면에서는 이미지를 확대해 확인하세요.
첨부 핀맵 원본 크게 보기사용자 제공 LILYGO T-Display ESP32-S3 핀맵 원본

LILYGO 공식 핀맵의 부품면을 보고 USB-C가 아래인 방향에서는 왼쪽 열이 위에서부터 3V, 1, 2, 3, 10 … 순이며 하단에 GND와 5V가 있다. 화면 쪽으로 뒤집어 보면 좌우가 바뀐다. 반드시 실물 실크의 1·2·10·GND·5V를 다시 확인한다.

DAC 뒷면의 I2S/L/H·Left Justified 표기는 사진에 보이지만, 납땜 상태만으로 DIF 전압을 확정하지 않는다. ESS 데이터시트에서 DIF LOW는 I²S, HIGH는 Left Justified이므로 회로 추적 또는 측정으로 확인한다. 점퍼는 임의 변경하지 않는다.

근거: LILYGO 공식 T-Display-S3 저장소의 핀맵, ESS ES9023 데이터시트 v0.72, 이 프로젝트의 두 오디오 스케치, 사용자 제공 DAC 앞·뒷면 사진. 제조사 칩 문서는 이 모듈의 전원 회로·점퍼 배선을 보증하지 않는다.

03테스트 1 — 소리부터 확인하기

첫 번째는 인터넷 연결 없이 DAC와 출력 경로를 확인하는 시험이다. 초기에는 사인파로 준비했지만, 실제 후속 시험은 도레미파솔 음계 출력으로 변경해 성공했다(사용자 확인). 아래 Test1_Tone.ino는 초기 사인파 참고 코드이며, 성공한 음계 출력 코드와 동일한 파일은 아니다.

빌드 성공과 실제 소리 출력은 다른 결과다. 업로드가 끝났다는 로그만으로 재생에 성공했다고 쓰지 않고, 좌·우 채널의 실제 관찰까지 남긴다.

  • 사용한 보드 옵션과 코어 버전, 빌드·업로드 로그를 저장한다.
  • I²S 초기화 결과와 쓰기 경고 여부를 기록한다.
  • 좌·우 출력, 잡음과 비정상 발열 여부를 확인한다.

현재 결과: PASS — 사용자 확인. 세부 로그·측정값·실행 버전은 별도 제공되지 않았으므로 추가 관찰 결과를 임의로 기재하지 않는다.

04테스트 2 — Wi-Fi 연결·오디오 시험

사인파 출력이 확인되면 Test2_Radio.ino로 넘어간다. 네트워크 연결, 오디오 디코딩, 실제 음악 재생을 각각 나누어 기록한다.

원본 계획의 연속 재생 관찰 시간은 30분이다. 시작·종료 시각과 끊김, 발열, 전원 불안정 여부를 기록해야 ‘잠깐 소리가 났다’와 ‘안정적으로 재생했다’를 구분할 수 있다.

현재 결과: PASS · 사용자 확인. Wi-Fi는 2.4GHz 대역에서만 정상 연결됐다. 이 결과를 모든 대역이나 장시간 스트리밍 안정성 통과로 확대하지 않는다.

05테스트 3 — 화면과 버튼

마지막 기본 시험은 LCD와 버튼이다. 정확한 제품에 맞는 공식 예제로 화면을 먼저 확인하고, 버튼별 입력 기록을 남긴다. 화면이 켜졌다는 것과 두 버튼이 정상적으로 동작한다는 것도 따로 확인한다.

이제 오디오 두 종류와 LCD·버튼 단독 시험 코드를 준비했다. 아래 코드 섹션에서 전체 소스를 볼 수 있다. 오디오와 UI 통합은 단독 시험 이후 단계다.

현재 결과: PASS · 사용자 확인. 세부 버튼 입력 로그는 별도로 제공되지 않았다.

06DAC 회로 확대 기록

DAC 확대 사진. ES9023 실크와 금속 캔의 50.000 표기가 보인다. 발진기 존재와 실제 MCLK 동작 확인은 별개다.
사진 5. DAC 확대 사진. ES9023 실크와 금속 캔의 50.000 표기가 보인다. 발진기 존재와 실제 MCLK 동작 확인은 별개다.
DAC IC와 주변 회로 확대. 부품 배치 기록용이며 전원·클록 측정이나 소리 출력의 증거는 아니다.
사진 6. DAC IC와 주변 회로 확대. 부품 배치 기록용이며 전원·클록 측정이나 소리 출력의 증거는 아니다.

사진 6장은 HTML 안에 내장했다. 원본 사진은 수정하지 않았다. 부품 사진은 제작 과정의 기록이며, 완성된 플레이 모습은 글 마지막에 담았다.

07결과는 증거와 함께 채운다

시험 완료 후 작성할 결과 기록 항목
기록남길 내용
환경보드·DAC 모델, 코드 버전, 코어와 라이브러리
실행명령, 종료 코드, 빌드·업로드·시리얼 로그
관찰측정값, 좌·우 출력, 화면·버튼, 관찰 시간
판정PASS / FAIL / BLOCKED와 해당 근거

기본 시험을 거쳐 Spotify 재생과 보드 정상 동작을 확인했다. 이 제작기는 실제 확인한 구성과 결과를 중심으로 마무리한다.

Arduino IDE에서 라이브러리 설치하기

테스트 3에서 Arduino_GFX_Library.h가 없다는 오류가 나면 아래 순서대로 설치한다. 코드의 include 줄을 다른 이름으로 바꾸지 않는다.

  1. Arduino IDE를 실행한다.
  2. 도구(Tools) → 라이브러리 관리(Manage Libraries)를 연다. IDE 2에서는 왼쪽 라이브러리 매니저 아이콘으로도 열 수 있다.
  3. 검색창에 GFX Library for Arduino를 입력한다. GFX 또는 Arduino_GFX로도 검색할 수 있다.
  4. GFX Library for Arduino — Moon On Our Nation을 찾는다. Adafruit GFX Library와 혼동하지 않는다.
  5. 버전을 1.6.5로 선택하고 설치(Install)를 누른다. 이 문서의 코드는 이 버전으로 컴파일했다.
  6. 설치 완료 후 Test3_DisplayButtons.ino를 열고 확인(Verify, 체크 표시)을 누른다.

테스트 2와 보드 패키지도 준비

라이브러리 매니저에서 ESP32-audioI2S-master 3.4.2, 보드 매니저에서 esp32 by Espressif Systems 3.3.0을 설치한다. Test2_Radio 폴더의 secrets.example.h를 secrets.h로 복사하고 실제 Wi-Fi 정보는 로컬에서만 입력한다.

공유본 Wi-Fi 이름: [비공개] / 암호: [비공개]
아래 다운로드와 코드에는 실제 값이 없고 빈 설정 예제만 포함한다. 기존 로컬 secrets.h는 공유하지 않는다.

압축 해제 → 스케치 열기 → 확인 → 업로드

각 테스트 폴더의 같은 이름 .ino를 연다. 세 테스트를 한 폴더에 합치지 않는다. 실제 보드 포트를 선택해 업로드한 뒤, 시리얼 모니터 115200 baud에서 로그를 확인한다. 테스트1은 l/r/b/0, 테스트2는 r/0 명령을 입력하고 Enter를 누른다. 업로드는 기존 펌웨어를 대체한다.

08실행할 테스트 코드

세 스케치를 각각 독립된 폴더에 저장한다. 폴더 이름과 .ino 이름을 맞추고 한 번에 하나씩 업로드한다. 업로드는 기존 펌웨어를 대체한다. 아래 코드는 실제 컴파일을 통과했다. 테스트 1은 도레미파솔로 변경 후 성공, 테스트 2는 2.4GHz Wi-Fi에서 통과, 테스트 3도 사용자 통과 보고가 있다. 아래 코드는 초기 검증본으로, 도레미파솔 변경본 전체 소스는 포함하지 않는다.

실제 컴파일 결과 — 물리 출력 합격과 별개
스케치컴파일프로그램 크기실물 시험
Test1_TonePASS · exit 0362,367 bytes (초기 사인파)후속 도레미파솔 변경 시험 PASS · 사용자 확인
Test2_RadioPASS · exit 01,606,551 bytesPASS · 사용자 확인 / Wi-Fi 2.4GHz
Test3_DisplayButtonsPASS · exit 0407,427 bytesPASS · 사용자 확인

사인파의 초기 컴파일 오류는 Arduino.h의 TWO_PI 매크로와 이름 충돌이었다. 상수명을 PHASE_TAU로 바꾼 뒤 세 스케치 모두 다시 빌드했다.

테스트 1 — 초기 사인파 참고 코드

기본 무음. 시리얼에 l(좌), r(우), b(양쪽), 0(무음)를 보낸다. 30초 후 자동 무음. 앰프 음량을 최소로 시작하며, 초기화 팝 노이즈까지 제거하는 코드는 아니다.

Test1_Tone/Test1_Tone.ino

// T-Display S3 + ES9023. 단독 시험, 기본 무음. 실물 동작 미검증.
// Serial 115200: l=좌, r=우, b=양쪽, 0=무음, h=도움말.
#include <Arduino.h>
#include <ESP_I2S.h>
#include <math.h>

constexpr int PIN_BCK = 1;
constexpr int PIN_LRCK = 2;
constexpr int PIN_DATA = 10;
constexpr uint32_t SAMPLE_RATE = 44100;
constexpr int FRAMES = 256;
constexpr double PHASE_TAU = 6.283185307179586;
I2SClass dac;
int32_t pcm[FRAMES * 2];
double phase = 0.0;
char channel = '0';
uint32_t startedAt = 0;

void help() {
  Serial.println("TEST1 READY: 44100Hz / 32-bit slots / 440Hz; output muted");
  Serial.println("l=LEFT r=RIGHT b=BOTH 0=MUTE h=help; auto-mute after 30s");
}

uint32_t lastHeartbeat = 0;

void setup() {
  Serial.begin(115200);
  delay(1200);
  Serial.println("[BOOT] Test1_Tone serial-debug-v1 / 115200 baud");
  pinMode(15, OUTPUT);
  digitalWrite(15, HIGH);
  dac.setPins(PIN_BCK, PIN_LRCK, PIN_DATA, -1, -1);
  if (!dac.begin(I2S_MODE_STD, SAMPLE_RATE, I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO)) {
    while (true) {
      Serial.println("FAIL: I2S init");
      delay(2000);
    }
  }
  Serial.println("[INIT] I2S ready: BCK=1 LRCK=2 DATA=10 / no external MCLK");
  help();
}

void loop() {
  if (uint32_t(millis() - lastHeartbeat) >= 2000) {
    lastHeartbeat = millis();
    const char *name = "MUTED (waiting for command)";
    if (channel == 'l') name = "LEFT 440Hz";
    if (channel == 'r') name = "RIGHT 440Hz";
    if (channel == 'b') name = "BOTH 440Hz";
    Serial.printf("[STATUS] TEST1 uptime=%lus output=%s heap=%u\n",
      (unsigned long)(millis() / 1000), name, unsigned(ESP.getFreeHeap()));
    Serial.println("[ACTION] l=LEFT r=RIGHT b=BOTH 0=MUTE; auto-mute 30s; h=help");
  }
  while (Serial.available()) {
    char command = Serial.read();
    if (command == 'l' || command == 'r' || command == 'b' || command == '0') {
      channel = command;
      startedAt = millis();
      Serial.printf("OUTPUT=%c (0 means muted)\n", channel);
    } else if (command == 'h') {
      Serial.println("l=LEFT r=RIGHT b=BOTH 0=MUTE; 30s auto-mute");
    }
  }
  if (channel != '0' && uint32_t(millis() - startedAt) >= 30000) {
    channel = '0';
    Serial.println("AUTO-MUTE: 30s elapsed");
  }
  for (int i = 0; i < FRAMES; ++i) {
    // 약 -22dBFS. 음량 크기의 안전 보장은 아니므로 앰프 음량을 최소로 시작.
    int16_t small = static_cast<int16_t>(2500.0 * sin(phase));
    int32_t sample = static_cast<int32_t>(small) * 65536;
    pcm[2 * i] = (channel == 'l' || channel == 'b') ? sample : 0;
    pcm[2 * i + 1] = (channel == 'r' || channel == 'b') ? sample : 0;
    phase += PHASE_TAU * 440.0 / SAMPLE_RATE;
    if (phase >= PHASE_TAU) phase -= PHASE_TAU;
  }
  size_t count = dac.write(reinterpret_cast<uint8_t *>(pcm), sizeof(pcm));
  if (count != sizeof(pcm)) {
    channel = '0';
    Serial.printf("FAIL: short I2S write %u/%u; muted\n", unsigned(count), unsigned(sizeof(pcm)));
  }
}

테스트 2 — Wi-Fi 시험 참고 코드

사용자 시험 통과. Wi-Fi는 2.4GHz 대역에서만 정상 연결됐다. 기존 파일명 Test2_Radio는 유지하되, 테스트 1의 도레미파솔 시험과 구분한다.

기본 정지. 아래 secrets.example.h를 같은 폴더에 저장하고 secrets.h로 복사해 SSID/비밀번호를 로컬에서 입력한다. r 시작, 0 정지. Wi-Fi 20초 제한, 5초마다 상태 기록. HTTPS 연결 중에는 시리얼 정지 처리가 지연될 수 있다. 30분 경과는 자동 PASS가 아니다. 예시 스트림 주소는 이번 세션에서 실제 재생하지 않았다.

Test2_Radio/Test2_Radio.ino

// ESP32-audioI2S-master 3.4.2 전용. 기본 정지, 실제 재생 미검증.
#include <Arduino.h>
#include <WiFi.h>
#include <Audio.h>
#if __has_include("secrets.h")
#include "secrets.h"
#else
#include "secrets.example.h"
#endif
constexpr int PIN_BCK = 1;
constexpr int PIN_LRCK = 2;
constexpr int PIN_DATA = 10;
Audio audio;
// 0=idle, 1=Wi-Fi 연결 대기, 2=스트림 관찰
int state = 0;
uint32_t connectAt = 0;
uint32_t startedAt = 0;
uint32_t lastStatus = 0;
uint32_t stoppedAt = 0;
bool stopped = false;
bool milestone = false;

void info(Audio::msg_t message) {
  Serial.printf("[audio] %s: %s\n", message.s ? message.s : "", message.msg ? message.msg : "");
}
void stopRadio() {
  audio.setVolume(0);
  audio.stopSong();
  WiFi.disconnect(false, false);
  state = 0;
  Serial.println("STOPPED. r=start, 0=stop");
}
uint32_t lastHeartbeat = 0;

void setup() {
  Serial.begin(115200);
  delay(1200);
  Serial.println("[BOOT] Test2_Radio serial-debug-v1 / 115200 baud");
  pinMode(15, OUTPUT);
  digitalWrite(15, HIGH);
  if (!psramFound()) {
    while (true) {
      Serial.println("BLOCKED: PSRAM unavailable; select OPI PSRAM");
      delay(2000);
    }
  }
  Serial.println("[INIT] PSRAM available; configuring I2S BCK=1 LRCK=2 DATA=10");
  Audio::audio_info_callback = info;
  if (!audio.setPinout(PIN_BCK, PIN_LRCK, PIN_DATA)) {
    while (true) {
      Serial.println("FAIL: I2S pinout");
      delay(2000);
    }
  }
  audio.setVolume(0);
  audio.setConnectionTimeout(5000, 12000);
  WiFi.persistent(false);
  WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(false);
  Serial.println("TEST2 READY (stopped): r=start, 0=stop, h=help");
}
void loop() {
  if (uint32_t(millis() - lastHeartbeat) >= 2000) {
    lastHeartbeat = millis();
    const char *name = "IDLE (waiting for r)";
    if (state == 1) name = "WIFI CONNECTING";
    if (state == 2) name = "STREAM OBSERVATION";
    Serial.printf("[STATUS] TEST2 uptime=%lus state=%s wifi=%d credentials=%s\n",
      (unsigned long)(millis() / 1000), name, int(WiFi.status()),
      WIFI_SSID[0] == '\0' ? "MISSING: edit local secrets.h" : "CONFIGURED");
    Serial.println("[ACTION] r=start 0=stop h=help; stream logs do not prove audible output");
  }
  while (Serial.available()) {
    char command = Serial.read();
    if (command == '0') stopRadio();
    if (command == 'h') Serial.println("r=start; 0=stop; configure local secrets.h first");
    if (command == 'r' && state == 0) {
      if (WIFI_SSID[0] == '\0') {
        Serial.println("BLOCKED: copy secrets.example.h to secrets.h and set Wi-Fi locally");
      } else {
        WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
        connectAt = millis();
        state = 1;
        Serial.println("WIFI CONNECTING (20s timeout)");
      }
    }
  }
  if (state == 1) {
    if (WiFi.status() == WL_CONNECTED) {
      Serial.println("WIFI CONNECTED; opening stream");
      audio.setVolume(3); // 0..21; 외부 앰프 음량은 최소로 시작.
      if (!audio.connecttohost(STREAM_URL)) {
        Serial.println("FAIL: stream connection rejected");
        stopRadio();
      } else {
        startedAt = millis();
        lastStatus = startedAt;
        stopped = false;
        milestone = false;
        state = 2;
        Serial.println("STREAM ACCEPTED; audible L/R output still needs human verification");
      }
    } else if (uint32_t(millis() - connectAt) >= 20000) {
      Serial.println("FAIL: Wi-Fi timeout");
      stopRadio();
    }
  }
  if (state == 2) {
    audio.loop();
    if (WiFi.status() != WL_CONNECTED) {
      Serial.println("FAIL: Wi-Fi lost; observation ended");
      stopRadio();
    } else {
      uint32_t now = millis();
      if (!audio.isRunning()) {
        if (!stopped) { stopped = true; stoppedAt = now; }
        if (uint32_t(now - stoppedAt) >= 10000) {
          Serial.println("FAIL: library not running for 10s");
          stopRadio();
        }
      } else {
        stopped = false;
      }
      if (state == 2 && uint32_t(now - lastStatus) >= 5000) {
        lastStatus = now;
        Serial.printf("OBSERVE elapsed=%lus running=%d rate=%u bits=%u channels=%u heap=%u\n",
          (unsigned long)((now - startedAt) / 1000), audio.isRunning(),
          unsigned(audio.getSampleRate()), unsigned(audio.getBitsPerSample()),
          unsigned(audio.getChannels()), unsigned(ESP.getFreeHeap()));
      }
      if (state == 2 && !milestone && uint32_t(now - startedAt) >= 1800000) {
        milestone = true;
        Serial.println("30 MIN ELAPSED: not automatic PASS; review audible gaps, L/R and temperature");
      }
    }
  }
  delay(1);
}

Wi-Fi 설정 예제 — 비밀값 없음

아래 파일도 라디오 스케치와 같은 폴더에 저장한다. 실제 비밀번호가 들어간 secrets.h와 이를 포함해 빌드한 바이너리는 공개하지 않는다.

#pragma once
// 로컬 secrets.h로 복사 후 수정. 이 파일이나 비밀번호를 공개하지 마세요.
constexpr const char *WIFI_SSID = "";
constexpr const char *WIFI_PASSWORD = "";
constexpr const char *STREAM_URL = "https://ice5.somafm.com/groovesalad-128-mp3";

테스트 3 — LCD·버튼

DAC 없이 보드 단독 시험을 권장한다. 5색 표시 후 가로 화면에 카운터가 나온다. 부팅 후 BOOT(GPIO0)와 USER(GPIO14)를 눌러 해당 카운터 증가를 확인한다. BOOT를 누른 채 리셋하지 않는다. 터치 센서는 시험 대상이 아니다.

Test3_DisplayButtons/Test3_DisplayButtons.ino

// T-Display S3 170x320 ST7789, Arduino_GFX 1.6.5 공식 장치 설정 기반.
// LCD/버튼 단독 시험: DAC 출력 없음. 터치 패널 자체는 시험하지 않음.
#include <Arduino.h>
#include <Arduino_GFX_Library.h>

Arduino_DataBus *bus = new Arduino_ESP32LCD8(
  7, 6, 8, 9,                         // DC, CS, WR, RD
  39, 40, 41, 42, 45, 46, 47, 48);   // D0..D7
Arduino_GFX *screen = new Arduino_ST7789(
  bus, 5, 1, true, 170, 320, 35, 0, 35, 0);
const int buttonPins[2] = {0, 14};
bool rawState[2] = {true, true};
bool stableState[2] = {true, true};
uint32_t changedAt[2] = {0, 0};
uint32_t presses[2] = {0, 0};
uint32_t lastDraw = 0;

void drawStatus() {
  screen->fillScreen(0x0000);
  screen->drawRect(0, 0, screen->width(), screen->height(), 0xFFFF);
  screen->setTextColor(0xFFFF);
  screen->setTextSize(2);
  screen->setCursor(8, 8);
  screen->println("TEST3 LCD / BUTTONS");
  screen->setCursor(8, 42);
  screen->printf("BOOT  GPIO0 : %lu", (unsigned long)presses[0]);
  screen->setCursor(8, 70);
  screen->printf("USER GPIO14 : %lu", (unsigned long)presses[1]);
  screen->setCursor(8, 98);
  screen->printf("Uptime: %lus", (unsigned long)(millis() / 1000));
  screen->fillRect(8, 132, 96, 24, 0xF800);
  screen->fillRect(112, 132, 96, 24, 0x07E0);
  screen->fillRect(216, 132, 96, 24, 0x001F);
}
uint32_t lastHeartbeat = 0;

void setup() {
  Serial.begin(115200);
  delay(1200);
  Serial.println("[BOOT] Test3_DisplayButtons serial-debug-v1 / 115200 baud");
  pinMode(15, OUTPUT);
  digitalWrite(15, HIGH);
  pinMode(38, OUTPUT);
  digitalWrite(38, LOW);
  for (int i = 0; i < 2; ++i) {
    pinMode(buttonPins[i], INPUT_PULLUP);
    rawState[i] = digitalRead(buttonPins[i]);
    stableState[i] = rawState[i];
  }
  Serial.println("[INIT] LCD initialization starting; power=15 backlight=38");
  if (!screen->begin()) {
    while (true) {
      Serial.println("FAIL: LCD initialization");
      delay(2000);
    }
  }
  digitalWrite(38, HIGH);
  const uint16_t colors[] = {0xF800, 0x07E0, 0x001F, 0xFFFF, 0x0000};
  for (int i = 0; i < 5; ++i) {
    screen->fillScreen(colors[i]);
    Serial.printf("COLOR TEST %d/5\n", i + 1);
    delay(700);
  }
  drawStatus();
  Serial.println("TEST3 READY: press BOOT and USER after boot; LCD pixels need visual inspection");
}
void loop() {
  uint32_t now = millis();
  if (uint32_t(now - lastHeartbeat) >= 2000) {
    lastHeartbeat = now;
    Serial.printf("[STATUS] TEST3 uptime=%lus BOOT=%lu USER=%lu raw0=%d raw14=%d\n",
      (unsigned long)(now / 1000), (unsigned long)presses[0],
      (unsigned long)presses[1], digitalRead(0), digitalRead(14));
    Serial.println("[ACTION] Press BOOT(GPIO0) or USER(GPIO14) after boot; check LCD counter");
  }
  bool redraw = false;
  for (int i = 0; i < 2; ++i) {
    bool value = digitalRead(buttonPins[i]);
    if (value != rawState[i]) {
      rawState[i] = value;
      changedAt[i] = now;
    }
    if (stableState[i] != rawState[i] && uint32_t(now - changedAt[i]) >= 35) {
      stableState[i] = rawState[i];
      if (!stableState[i]) {
        ++presses[i];
        Serial.printf("BUTTON GPIO%d PRESS count=%lu\n", buttonPins[i], (unsigned long)presses[i]);
        redraw = true;
      }
    }
  }
  if (redraw || uint32_t(now - lastDraw) >= 1000) {
    lastDraw = now;
    drawStatus();
  }
  delay(1);
}

시험 기록 방법

빌드 → 업로드 → 시리얼 로그 → 실제 소리·화면·버튼 결과를 각각 기록한다. 사인파는 좌·우를 따로 확인하고, 라디오는 30분의 끊김·발열을 관찰한다. LCD는 색과 화면 가장자리, 버튼별 단발·길게 누르기 동작을 확인한다. 컴파일 성공만으로 이러한 항목을 PASS 처리하지 않는다.

07. 테스트 3 다음 — LILYGO 단독 Spotify Connect

기본 오디오와 화면 시험을 마친 뒤에는 네트워크 음악 재생으로 범위를 넓혔다. Arduino 테스트와는 별도의 ESP-IDF 프로젝트를 사용했으며, Mac은 개발·빌드·업로드 도구로만 사용했다. 상시 실행 PC나 별도 음악 서버 없이 보드가 오디오를 처리하는 구성을 목표로 했다.

Spotify 앱과 서버, Wi-Fi 공유기, LILYGO 및 ES9023 오디오 경로
v6의 전체 구성도. 폰은 제어하고 LILYGO가 스트림을 받아 DAC로 출력하는 큰 흐름을 보여줍니다. 그림의 같은 LAN 및 LCD·버튼 “다음 단계” 표기는 초기 구성 당시 설명입니다. 이후 UI·재생 상태는 마지막 사례를, 초기 연결과 외부 제어 경로의 구분은 아래 v8 구조도를 참고하세요.

앱은 제어하고, 보드는 오디오를 출력한다

앱에서 기기를 선택해 재생을 제어하고, 보드가 네트워크로 받은 오디오를 처리해 ES9023으로 보낸다. 초기 연결과 다른 네트워크에서의 제어, 재부팅 후 복구는 각각 별도의 시험 항목으로 구분했다. 모든 조건에서의 동작이 검증됐다는 의미는 아니다.

외부 스마트폰은 로컬 박스 밖에 있고 초기 연결만 점선으로 표시한 Spotify 구조도
수정 구조도. 서비스의 원격 제어 구조와 이 포트에서 실제 확인한 기능은 별개다. 기기에 포트포워딩을 설정하는 방식이 아니다.

08. 준비물과 개발환경

  • LILYGO T-Display S3와 ES9023 DAC, 앰프 또는 액티브 스피커.
  • I²S 신호는 GPIO1→BCK, GPIO2→LRCK, GPIO10→DATA를 사용하며 앞선 전원·접지·클록 확인 조건을 유지한다.
  • 초기 개발에는 ESP-IDF v6.0.1을 사용했다. 앞의 Arduino 시험과는 별도 프로젝트다.

09. 개발 과정

보드 초기화, 네트워크 연결, 플레이어 동작과 오디오 출력을 단계별로 확인했다. 계정 연결 과정에는 여러 차례 조정이 필요했지만, 공개 글에는 관련 설정값·요청 형식·내부 처리 절차를 싣지 않았다.

10. 빌드와 실제 소리는 다르다

초기 포트의 컴파일 성공은 개발 기록으로 남겼다. 하지만 빌드 성공이나 기기 검색, 곡 정보 표시만으로 재생 성공을 판정하지 않았다. 실제 DAC 출력과 청취를 따로 확인했고, 이후 제작자가 재생 성공을 보고했다. 이후 제작자가 보드에서 재생과 정상 동작을 확인해 이번 제작을 마무리했다.

11. 구현 구성 한눈에 보기

  • 실행부: 보드 초기화와 네트워크·플레이어 상태 관리.
  • 오디오 처리부: 받은 오디오를 PCM으로 처리하고 출력 버퍼에 전달.
  • 출력부: I²S 신호를 ES9023으로 보내 아날로그 소리로 변환.
  • 표시부: 후속 개발에서 연결·곡 정보·재생 상태를 화면에 표시.

12. 제작 완료

기본 시험과 네트워크 연결을 거쳐 보드에서 실제 음악 재생과 정상 동작을 확인했다. 제작자의 최종 확인을 기준으로 이번 제작을 완료하며, 별도의 남은 작업 목록은 두지 않는다.

15드디어 재생 성공 — 제작의 다음 장

처음에는 사인파로 준비하고, 후속 테스트 1은 도레미파솔로 변경해 배선과 DAC 출력을 확인했다. 테스트 2 Wi-Fi 시험도 통과했으며, 2.4GHz 대역에서만 정상 연결을 확인했다. 그다음 화면과 버튼을 붙이고, Spotify에서 기기가 보이는지, 인증과 음원 처리가 어디까지 이어지는지 하나씩 확인했다. 연결과 곡 정보 표시만으로는 끝이 아니었다. 마지막에는 실제 오디오가 나오는지를 확인해야 했다.

그 과정을 지나 제작자가 재생 성공을 확인했다. 이번 추가 기록은 그 소식과 기존 플레이 사진을 함께 남기는 짧은 후기다. 보드에서의 재생과 정상 동작까지 제작자가 확인했으며, 이번 제작은 완료했다.

기존 실기 사진: LCD에 LOVE ATTACK, PLAYING, 볼륨 70%가 표시된 플레이어. 로컬 IP는 가린 사본.
기존 플레이 사진. LCD의 LOVE ATTACK·PLAYING·70% 표시를 확인할 수 있다. 로컬 IP는 공개용 사본에서 가렸으며 원본은 변경하지 않았다. 이 사진은 이번 성공 보고 때 새로 촬영한 사진이 아니라 앞서 제공된 기록이다.

완성 결과

구분현재 기록
재생 성공제작자가 보드에서 실제 재생과 정상 동작을 직접 확인했다.
기존 실기 근거후속 문서의 일부 곡 Ogg·비영 PCM·I²S 출력 기록과 LCD 플레이 사진. 화면 표시만으로 실음·안정성을 판정하지 않는다.
최종 ESP32 확인보드 재생·정상 동작 확인 · 제작자 보고.

작은 보드에서 음악이 나오기까지

도레미파솔 출력 시험부터 2.4GHz Wi-Fi 연결, 화면과 버튼, 실제 음악 재생까지 이어졌다. 제작자가 보드의 정상 동작을 확인했고, 이번 제작은 완료했다.

v8 제작 문서를 바탕으로 v6 구성도·공통 부품 사진, 사용자 첨부 핀맵과 후속 재생 사례를 통합한 블로그 파일입니다. 초기 코드와 원본 사진은 보존했습니다. 제작자의 보드 재생·정상 동작 확인을 반영한 완성 기록입니다.

댓글

이 블로그의 인기 게시물

Install NS2.35 to Mac OS(Lion)

Running local LLMs on two P104-100 GPUs

Test DHT11 and SEN0018 raspberry