Hardware components | ||||||
| × | 1 | ||||
![]() |
| × | 1 | |||
Software apps and online services | ||||||
| ||||||
![]() |
| |||||
![]() |
|
Over the past few years, I’ve explored several projects with EEG data - often combining it with machine learning. Until now, every prototype and final solution has relied on a computer. While that works fine (and sometimes is the only option), it’s not very portable.
That’s why I wanted to see if an ESP32-powered Elecrow CrowPanel display could handle EEG data directly. Could I create something lightweight, simple, and self-contained? Thanks to Elecrow’s generous donation, I got the chance to find out.
Parts neededElecrowdisplay
The display I received is a CrowPanel 7.0" touch screen with a resolution of 800×640. It uses the ESP32-S3-WROOM-1-N4R8 as its main controller, featuring a dual-core 32-bit LX6 processor at up to 240 MHz. It has 16 MB of Flash memory and 512 KB of SRAM.
MuseEEG-headband
For this particular project, I used an Interaxon Muse 2016 EEG headband. It has four dry EEG electrodes: two behind the ears and two on the forehead, plus a reference electrode.
MindMonitorphoneapp
The headband transmits data via Bluetooth. While it should technically be possible to transmit data directly to the Bluetooth-equipped Elecrow display, I wanted to keep the project scope compact and decided instead to use the MindMonitor phone app I’ve used previously. The app receives data from the EEG headband and forwards it to the display via Wi-Fi in a standardized OSC format.
Setting it all upHardwareand apps
This is pretty much plug and play:
- Connect your Muse EEG headband (any version from 2016 onwards) to MindMonitor, and make sure you get good data.
- Open settings in MindMonitor, scroll down until
OSC Stream Target IP
, where you'll later insert the IP-address of your display once you know it. SetOSC Stream Port
to 5000. - Connect your Elecrow display to your computer.
Developmentenvironment
You could use the Arduino IDE, but I prefer VS Code + PlatformIO.
To kick-start, I downloaded a ready-made Elecrow example project, compiled it, and then rewamped it into something completely new. The final code is available on this Hackster page.
Wi-Fiparameters
Inside main.cpp
(around row 181) you’ll find variables for Wi-Fi SSID and password. These are still hard-coded (prototype style), but should obviously be secured in a real deployment.
Compile and deploy the program
Each PlatformIO project needs its own platformio.ini
. Mine is attached to the Hackster repo. Depending on your environment, you may need to tweak a few parameters.
Once your SSID and password are set, you should be ready to compile and upload.
IP-address of thedisplay
The display’s IP address is visible under the Settings tab. Enter this into MindMonitor, and you’re ready to stream EEG data.
Bonus: from the same tab you can also adjust brightness or view the raw OSC data feed.
Bands tab
Live oscilloscope of the five Muse EEG bands (delta/theta/alpha/beta/gamma) with adaptive auto-scaling and smoothing so trends are easy to see in real time.
Spectrum tab
Real-time 1–20 Hz (configurable) frequency spectrum from AF7 using FFT, normalized and smoothed to show moment-to-moment power across frequencies with a peak readout.
Aurora tab
An ambient, animated visualization where flowing “ribbons” react to the bands (amplitude and motion follow band energy), creating an engaging biofeedback backdrop.
Biofeedback tab
Dual gauges for Focus and Mellow computed from band ratios (with adaptive normalization), giving simple 0–100% indicators you can train against.
The project’s main goal was to see whether EEG data from a Muse headband could be streamed to an ESP32-powered Elecrow display in a lightweight, standalone way. Mission accomplished ✅
But it didn’t stop there:
- I now have multiple engaging visualizations running directly on the display.
- It sparked ideas for future work -like skipping the phone app and streaming directly via BLE. (Unfortunately, the Muse SDK isn’t freely available, so that’s a bigger challenge.)
- Visualizations are intentionally simple, but there’s plenty of room to expand with more creativity.
Overall, I was pleasantly surprised at just how developer-friendly the Elecrow CrowPanel is. It’s a low-cost device, yet it handled EEG visualization smoothly -proving that biofeedback projects don’t always need a heavy PC setup.
👉 If you’re curious about EEG, biofeedback, or just want to try a fun project with your Elecrow display, give this a shot!
// Program: ESP32-S3 Elecrow 7" HMI — Bands scope, FFT spectrum, Aurora, and computed Focus/Mellow + Settings tab (slider + raw). 2025-09-16 00:40 (Europe/Helsinki) — Thomas Vikström.
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <cstring>
#include <cstdint>
#include <cmath>
#include <lvgl.h>
#include <esp_heap_caps.h>
#include <LovyanGFX.hpp>
#include <lgfx/v1/platforms/esp32s3/Panel_RGB.hpp>
#include <lgfx/v1/platforms/esp32s3/Bus_RGB.hpp>
#include "touch.h" // your existing touch driver
#include "ui.h" // optional (SquareLine UI)
#include <arduinoFFT.h> // lib_deps: lvgl/lvgl @ ^8.3.11, kosme/arduinoFFT @ ^1.6
/* =========================
===== Display setup =====
========================= */
class LGFX : public lgfx::LGFX_Device {
public:
lgfx::Bus_RGB _bus;
lgfx::Panel_RGB _panel;
LGFX(void) {
{ // RGB bus config
auto cfg = _bus.config();
cfg.panel = &_panel;
cfg.pin_d0 = GPIO_NUM_15; cfg.pin_d1 = GPIO_NUM_7; cfg.pin_d2 = GPIO_NUM_6; cfg.pin_d3 = GPIO_NUM_5; cfg.pin_d4 = GPIO_NUM_4;
cfg.pin_d5 = GPIO_NUM_9; cfg.pin_d6 = GPIO_NUM_46; cfg.pin_d7 = GPIO_NUM_3; cfg.pin_d8 = GPIO_NUM_8; cfg.pin_d9 = GPIO_NUM_16;
cfg.pin_d10 = GPIO_NUM_1; cfg.pin_d11 = GPIO_NUM_14; cfg.pin_d12 = GPIO_NUM_21; cfg.pin_d13 = GPIO_NUM_47; cfg.pin_d14 = GPIO_NUM_48;
cfg.pin_d15 = GPIO_NUM_45;
cfg.pin_henable = GPIO_NUM_41; cfg.pin_vsync=GPIO_NUM_40; cfg.pin_hsync=GPIO_NUM_39; cfg.pin_pclk=GPIO_NUM_0;
cfg.freq_write = 14000000;
cfg.hsync_polarity=0; cfg.hsync_front_porch=40; cfg.hsync_pulse_width=48; cfg.hsync_back_porch=40;
cfg.vsync_polarity=0; cfg.vsync_front_porch=1; cfg.vsync_pulse_width=31; cfg.vsync_back_porch=13;
cfg.pclk_active_neg=1; cfg.de_idle_high=0; cfg.pclk_idle_high=0;
_bus.config(cfg);
}
{ // Panel config
auto cfg = _panel.config();
cfg.memory_width=800; cfg.memory_height=480; cfg.panel_width=800; cfg.panel_height=480; cfg.offset_x=0; cfg.offset_y=0;
_panel.config(cfg);
}
_panel.setBus(&_bus);
setPanel(&_panel);
}
};
LGFX lcd;
/* =========================
===== LVGL plumbing =====
========================= */
static uint32_t screenW, screenH;
static lv_disp_draw_buf_t draw_buf;
static lv_disp_drv_t disp_drv;
static lv_color_t* buf1 = nullptr;
static lv_color_t* buf2 = nullptr;
static int draw_lines = 80;
static void lvgl_flush_cb(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
uint32_t w = (area->x2 - area->x1 + 1);
uint32_t h = (area->y2 - area->y1 + 1);
lcd.pushImageDMA(area->x1, area->y1, w, h, (lgfx::rgb565_t*)&color_p->full);
lv_disp_flush_ready(disp);
}
static void lvgl_touch_cb(lv_indev_drv_t *indev, lv_indev_data_t *data) {
if (touch_has_signal()) {
if (touch_touched()) { data->state = LV_INDEV_STATE_PRESSED; data->point.x = touch_last_x; data->point.y = touch_last_y; }
else if (touch_released()) { data->state = LV_INDEV_STATE_RELEASED; }
} else data->state = LV_INDEV_STATE_RELEASED;
}
/* =========================
===== Backlight PWM =====
========================= */
static constexpr int TFT_BL_PIN = 2;
static constexpr int BL_CH = 1; // LEDC channel
static constexpr int BL_FREQ = 300;
static constexpr int BL_RES = 8;
static inline void setBacklight(uint8_t lvl){ ledcWrite(BL_CH, lvl); }
/* =========================
===== UI components =====
========================= */
// Tabs
static lv_obj_t* tabview=nullptr;
static lv_obj_t* tab_bands=nullptr;
static lv_obj_t* tab_spec =nullptr;
static lv_obj_t* tab_aurora=nullptr;
static lv_obj_t* tab_bio=nullptr;
static lv_obj_t* tab_settings=nullptr;
// Settings widgets
static lv_obj_t* bl_slider=nullptr;
static lv_obj_t* bl_label =nullptr;
static lv_obj_t* lbl_ip=nullptr;
static lv_obj_t* lbl_port=nullptr;
// Raw bands label (moved to Settings)
static lv_obj_t* band_label=nullptr;
static const uint32_t UI_PERIOD_MS = 33; // ~30 FPS
static uint8_t backlight_level_255 = 160;
/* Bands (tab 1) */
static lv_obj_t* eeg_chart=nullptr;
static lv_chart_series_t *ser_delta=nullptr, *ser_theta=nullptr, *ser_alpha=nullptr, *ser_beta=nullptr, *ser_gamma=nullptr;
static const uint16_t EEG_POINTS=240;
static int chart_y_min=-50, chart_y_max=50; // autorange
static float band_vis_max=1.0f; uint8_t range_tick=0;
/* Spectrum (tab 2) */
static lv_obj_t* spec_chart=nullptr; static lv_chart_series_t* ser_spec=nullptr; static lv_obj_t* spec_info=nullptr;
static const int SPEC_MIN_HZ=1, SPEC_MAX_HZ=20, SPEC_BINS=(SPEC_MAX_HZ-SPEC_MIN_HZ+1);
/* Aurora (tab 3) */
static lv_obj_t* aurora_canvas=nullptr; static lv_color_t* aurora_buf=nullptr; static int aurora_w=0, aurora_h=0;
static int16_t* sin1024=nullptr;
/* Biofeedback meters (tab 4) */
struct MeterRefs {
lv_obj_t* container{nullptr};
lv_obj_t* meter{nullptr};
lv_meter_scale_t* scale{nullptr};
lv_meter_indicator_t* needle{nullptr};
lv_obj_t* label{nullptr};
};
static MeterRefs MET_FOCUS, MET_MELLOW;
/* =========================
===== OSC decoding =====
========================= */
static uint8_t oscBuf[1024];
static inline uint32_t be32(const uint8_t* p){ return (uint32_t(p[0])<<24)|(uint32_t(p[1])<<16)|(uint32_t(p[2])<<8)|(uint32_t(p[3])); }
static int parse_osc_element(const uint8_t *buf, int len, char *addrBuf, int addrBufSize, float *out, int max) {
if (!buf || len <= 0) return 0;
const char *addr = (const char*)buf; int addr_len = strnlen(addr, len); if (addr_len == len) return 0;
int off = ((addr_len + 1) + 3) & ~3; if (off >= len) return 0;
int copy_len = (addr_len < addrBufSize-1)? addr_len : (addrBufSize-1);
memcpy(addrBuf, addr, copy_len); addrBuf[copy_len]=0;
const char* tt = (const char*)(buf + off); int tt_len = strnlen(tt, len - off); if (tt_len==0 || tt[0]!=',') return 0;
off += ((tt_len+1)+3) & ~3; if (off > len) return 0;
int count=0;
for (int i=1; i<tt_len && off+4<=len && count<max; ++i) {
if (tt[i]=='f'){ uint32_t be=be32(buf+off); float f; memcpy(&f,&be,4); out[count++]=f; off+=4; }
else if (tt[i]=='i'){ uint32_t be=be32(buf+off); int32_t v; memcpy(&v,&be,4); out[count++]=(float)v; off+=4; }
else break;
}
return count;
}
static void extract_bands_from_packet(const uint8_t *buf, int len, float bands[5]) {
for (int i=0;i<5;++i) bands[i]=NAN; if (!buf || len<=0) return;
auto handle=[&](const uint8_t* m,int mlen){
char addr[128]; float v[16]; int n=parse_osc_element(m,mlen,addr,sizeof(addr),v,16);
if (n<=0) return; float avg=0; for(int i=0;i<n;++i) avg+=v[i]; avg/= (float)n;
if (strstr(addr,"delta_absolute")) bands[0]=avg;
else if (strstr(addr,"theta_absolute")) bands[1]=avg;
else if (strstr(addr,"alpha_absolute")) bands[2]=avg;
else if (strstr(addr,"beta_absolute")) bands[3]=avg;
else if (strstr(addr,"gamma_absolute")) bands[4]=avg;
};
if (len>=8 && memcmp(buf,"#bundle",7)==0) { if (len<16) return; int pos=16; while(pos+4<=len){ uint32_t sz=be32(buf+pos); pos+=4; if(sz==0||pos+(int)sz>len) break; handle(buf+pos,(int)sz); pos+=sz; } }
else handle(buf,len);
}
/* =========================
===== Networking =====
========================= */
static const char* WIFI_SSID = "your ssid here";
static const char* WIFI_PASS = "your password here";
static const uint16_t OSC_UDP_PORT = 5000;
static WiFiUDP Udp;
/* =========================
===== Bands smoothing =====
========================= */
static volatile int q_delta=0,q_theta=0,q_alpha=0,q_beta=0,q_gamma=0; static volatile bool q_have=false;
static float ema_delta=0, ema_theta=0, ema_alpha=0, ema_beta=0, ema_gamma=0;
static const float BAND_EMA_A=0.50f;
/* =========================
===== Spectrum (FFT) =====
========================= */
static const uint16_t FFT_N=64; static const double EEG_FS_HZ=256.0;
static double fft_vReal[FFT_N], fft_vImag[FFT_N]; static uint16_t fft_w=0; static bool fft_ready=false;
static arduinoFFT FFT;
static float spec_smooth[SPEC_BINS]={0}; static const float SPEC_EMA_A=0.40f;
volatile bool q_spec_have = false;
volatile uint8_t q_spec_vals[SPEC_BINS] = {0};
static inline double hp_filter(double x){ static double ema=0.0; const double a=0.005; ema=(1.0-a)*ema + a*x; return x-ema; }
static inline void fft_feed(double af7){ double x=hp_filter(af7); fft_vReal[fft_w]=x; fft_vImag[fft_w]=0.0; if(++fft_w>=FFT_N){ fft_w=0; fft_ready=true; } }
static void fft_compute_and_queue() {
if (!fft_ready) return; fft_ready=false;
FFT.Windowing(fft_vReal, FFT_N, FFT_WIN_TYP_HAMMING, FFT_FORWARD);
FFT.Compute(fft_vReal, fft_vImag, FFT_N, FFT_FORWARD);
FFT.ComplexToMagnitude(fft_vReal, fft_vImag, FFT_N);
auto mag_at_hz=[&](float f)->float{
if (f<=0) return 0.0f;
float bin=f*(float)FFT_N/(float)EEG_FS_HZ; if (bin<1.0f) bin=1.0f; float maxBin=(float)FFT_N*0.5f-1.0f; if (bin>maxBin) bin=maxBin;
int i0=(int)floorf(bin); int i1=i0+1; float frac=bin-(float)i0; float m0=(float)fft_vReal[i0]; float m1=(float)fft_vReal[i1];
return m0 + (m1-m0)*frac;
};
float mags[SPEC_BINS]; float peak=0; int peakHz=SPEC_MIN_HZ;
for (int h=SPEC_MIN_HZ; h<=SPEC_MAX_HZ; ++h) {
float v = log10f(mag_at_hz((float)h)+1e-6f)+6.0f; if (v<0) v=0;
mags[h-SPEC_MIN_HZ]=v; if (v>peak){ peak=v; peakHz=h; }
}
for (int i=0;i<SPEC_BINS;++i){
float s = (peak<1e-6f?0.0f:mags[i]/peak);
s = SPEC_EMA_A*s + (1.0f-SPEC_EMA_A)*spec_smooth[i];
spec_smooth[i]=s; int v=(int)lroundf(s*100.0f); if(v<0)v=0; if(v>100)v=100;
q_spec_vals[i]=(uint8_t)v;
}
q_spec_have=true;
if (spec_info){ char t[32]; snprintf(t,sizeof(t),"Peak: %d Hz",peakHz); lv_label_set_text(spec_info,t); }
}
/* =========================
===== Aurora helpers =====
========================= */
static inline void init_sinlut(){
if (!sin1024){
sin1024 = (int16_t*)heap_caps_malloc(sizeof(int16_t)*1024, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
if (!sin1024) sin1024 = (int16_t*)heap_caps_malloc(sizeof(int16_t)*1024, MALLOC_CAP_8BIT);
for (int i=0;i<1024;++i){ float ang=(2.0f*M_PI*i)/1024.0f; sin1024[i]=(int16_t)lroundf(sinf(ang)*32767.0f); }
}
}
static inline float fsin1024(int idx){ idx&=1023; return (float)sin1024[idx]/32767.0f; }
static inline lv_color_t aurora_color(int band){
switch(band){ case 0: return lv_color_hex(0x0A2A6D); case 1: return lv_color_hex(0x0F6E6D);
case 2: return lv_color_hex(0x1AAE48); case 3: return lv_color_hex(0xE79E2E);
default:return lv_color_hex(0xBB33CC); }
}
static const int AURORA_PTS=128; static const int AURORA_GLOW_SKIP=3; static uint16_t phase_idx[5]={0,128,256,384,512};
static void aurora_draw_frame(){
if (!aurora_canvas || !aurora_buf || !sin1024) return;
{ lv_draw_rect_dsc_t r; lv_draw_rect_dsc_init(&r); r.bg_color=lv_color_hex(0x061018); r.bg_opa=220;
lv_canvas_draw_rect(aurora_canvas, 0, 0, aurora_w, aurora_h, &r); }
float amps[5] = {
fminf(fabsf(ema_delta),3.0f)*60.0f, fminf(fabsf(ema_theta),3.0f)*60.0f,
fminf(fabsf(ema_alpha),3.0f)*60.0f, fminf(fabsf(ema_beta), 3.0f)*60.0f,
fminf(fabsf(ema_gamma),3.0f)*60.0f
};
const uint8_t speed[5]={2,2,3,4,5};
int pad=18; int usable_h=aurora_h-2*pad; int gap=usable_h/6; int base_y[5]; for(int i=0;i<5;++i) base_y[i]=pad+gap*(i+1);
lv_draw_line_dsc_t main_d; lv_draw_line_dsc_init(&main_d); main_d.width=6; main_d.opa=LV_OPA_COVER; main_d.round_start=1; main_d.round_end=1;
lv_draw_line_dsc_t glow_d=main_d; glow_d.width=12; glow_d.opa=96;
lv_point_t pts[AURORA_PTS]; lv_point_t gpts[AURORA_PTS]; const float x_step=(AURORA_PTS>1)?(float)(aurora_w-1)/(float)(AURORA_PTS-1):(float)aurora_w;
for (int b=0;b<5;++b){
main_d.color=aurora_color(b); glow_d.color=main_d.color;
float neighbor=0.0f; if(b>0) neighbor+=amps[b-1]*0.25f; if(b<4) neighbor+=amps[b+1]*0.25f;
for (int i=0;i<AURORA_PTS;++i){
int x=(int)lroundf(i*x_step); int idx=(phase_idx[b]+(x*3))&1023; float y=base_y[b]+fsin1024(idx)*amps[b];
int drift=(phase_idx[1]+x)&1023; y += fsin1024(drift)*neighbor*0.4f;
int yi=(int)lroundf(y); if (yi<0) yi=0; if(yi>=aurora_h) yi=aurora_h-1; pts[i].x=x; pts[i].y=yi;
}
lv_canvas_draw_line(aurora_canvas, pts, AURORA_PTS, &main_d);
int gcnt=0; for (int i=0;i<AURORA_PTS;i+=AURORA_GLOW_SKIP) gpts[gcnt++]=pts[i];
if (gcnt>1) lv_canvas_draw_line(aurora_canvas, gpts, gcnt, &glow_d);
phase_idx[b] += speed[b] + (uint8_t)(fabsf(ema_theta)*3.0f);
}
if (ema_gamma>0.02f){
uint8_t dots=(uint8_t)min(18.0f, ema_gamma*30.0f);
lv_draw_rect_dsc_t r; lv_draw_rect_dsc_init(&r); r.radius=1; r.bg_opa=140; r.bg_color=lv_color_hex(0xA0D8FF);
for (int i=0;i<dots;++i){ int x=(millis()*37+i*73)%aurora_w; int y=(millis()*53+i*29)%aurora_h; lv_canvas_draw_rect(aurora_canvas,x,y,2,2,&r); }
}
}
/* =========================
===== Biofeedback (computed Focus/Mellow) =====
========================= */
struct ZNorm {
float mean = 0.0f;
float mad = 1.0f; // mean absolute deviation (EMA)
float a = 0.01f; // update rate
inline void update(float x){
mean = (1.0f - a)*mean + a*x;
float dev = fabsf(x - mean);
mad = (1.0f - a)*mad + a*dev;
if (mad < 1e-6f) mad = 1e-6f;
}
inline float norm01(float x) const {
float z = (x - mean) / (3.0f*mad);
return 1.0f / (1.0f + expf(-z));
}
};
static ZNorm FOCUS_Z, MELLOW_Z;
static volatile int q_focus=-1, q_mellow=-1; static volatile bool q_bio_have=false;
static float ema_focus=0.0f, ema_mellow=0.0f; static const float BIO_EMA_A=0.40f;
static MeterRefs build_meter(lv_obj_t* parent, const char* title, lv_color_t color){
MeterRefs mr;
mr.container = lv_obj_create(parent);
lv_obj_set_size(mr.container, 360, 300);
lv_obj_set_style_bg_opa(mr.container, LV_OPA_0, 0);
lv_obj_set_style_border_width(mr.container, 0, 0);
lv_obj_set_style_pad_all(mr.container, 8, 0);
lv_obj_t* t = lv_label_create(mr.container);
lv_label_set_text(t, title);
lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 0);
mr.meter = lv_meter_create(mr.container);
lv_obj_set_size(mr.meter, 240, 240);
lv_obj_align(mr.meter, LV_ALIGN_CENTER, 0, 10);
mr.scale = lv_meter_add_scale(mr.meter);
lv_meter_set_scale_ticks(mr.meter, mr.scale, 41, 2, 8, lv_palette_main(LV_PALETTE_GREY));
lv_meter_set_scale_major_ticks(mr.meter, mr.scale, 10, 4, 14, lv_color_white(), 12);
lv_meter_set_scale_range(mr.meter, mr.scale, 0, 100, 240, 150);
lv_meter_indicator_t* arc_lo = lv_meter_add_arc(mr.meter, mr.scale, 10, lv_palette_main(LV_PALETTE_RED), 0);
lv_meter_set_indicator_start_value(mr.meter, arc_lo, 0); lv_meter_set_indicator_end_value(mr.meter, arc_lo, 40);
lv_meter_indicator_t* arc_mid = lv_meter_add_arc(mr.meter, mr.scale, 10, lv_palette_main(LV_PALETTE_AMBER), 0);
lv_meter_set_indicator_start_value(mr.meter, arc_mid, 40); lv_meter_set_indicator_end_value(mr.meter, arc_mid, 70);
lv_meter_indicator_t* arc_hi = lv_meter_add_arc(mr.meter, mr.scale, 10, color, 0);
lv_meter_set_indicator_start_value(mr.meter, arc_hi, 70); lv_meter_set_indicator_end_value(mr.meter, arc_hi, 100);
// Needle color
mr.needle = lv_meter_add_needle_line(mr.meter, mr.scale, 4, color, -12);
mr.label = lv_label_create(mr.container);
lv_label_set_text(mr.label, "-- %");
lv_obj_align(mr.label, LV_ALIGN_BOTTOM_MID, 0, -4);
return mr;
}
static inline void meter_set_value(const MeterRefs& mr, int val){
if (!mr.meter || !mr.scale || !mr.needle) return;
if (val<0) val=0; if (val>100) val=100;
lv_meter_set_indicator_value(mr.meter, mr.needle, val);
if (mr.label){ char t[24]; snprintf(t,sizeof(t), "%d %%", val); lv_label_set_text(mr.label, t); }
}
/* =========================
===== UI builders =====
========================= */
static void build_ui() {
// Tabs (order: Bands, Spectrum, Aurora, Biofeedback, Settings)
tabview = lv_tabview_create(lv_scr_act(), LV_DIR_TOP, 36);
tab_bands = lv_tabview_add_tab(tabview, "Bands");
tab_spec = lv_tabview_add_tab(tabview, "Spectrum");
tab_aurora = lv_tabview_add_tab(tabview, "Aurora");
tab_bio = lv_tabview_add_tab(tabview, "Biofeedback");
tab_settings= lv_tabview_add_tab(tabview, "Settings");
/* -------- Bands chart -------- */
eeg_chart = lv_chart_create(tab_bands);
lv_obj_set_size(eeg_chart, lv_pct(96), lv_pct(85));
lv_obj_align(eeg_chart, LV_ALIGN_BOTTOM_MID, 0, -6);
lv_chart_set_type(eeg_chart, LV_CHART_TYPE_LINE);
lv_chart_set_update_mode(eeg_chart, LV_CHART_UPDATE_MODE_SHIFT);
lv_chart_set_point_count(eeg_chart, EEG_POINTS);
lv_chart_set_range(eeg_chart, LV_CHART_AXIS_PRIMARY_Y, chart_y_min, chart_y_max);
lv_chart_set_div_line_count(eeg_chart, 4, 4);
lv_obj_set_style_bg_opa(eeg_chart, LV_OPA_0, 0);
lv_obj_set_style_border_width(eeg_chart, 0, 0);
lv_obj_set_style_line_width(eeg_chart, 2, LV_PART_ITEMS);
lv_obj_set_style_size(eeg_chart, 0, LV_PART_INDICATOR);
lv_obj_set_style_line_rounded(eeg_chart, true, LV_PART_ITEMS);
ser_delta = lv_chart_add_series(eeg_chart, lv_palette_main(LV_PALETTE_GREY ), LV_CHART_AXIS_PRIMARY_Y);
ser_theta = lv_chart_add_series(eeg_chart, lv_palette_main(LV_PALETTE_TEAL ), LV_CHART_AXIS_PRIMARY_Y);
ser_alpha = lv_chart_add_series(eeg_chart, lv_palette_main(LV_PALETTE_GREEN), LV_CHART_AXIS_PRIMARY_Y);
ser_beta = lv_chart_add_series(eeg_chart, lv_palette_main(LV_PALETTE_AMBER), LV_CHART_AXIS_PRIMARY_Y);
ser_gamma = lv_chart_add_series(eeg_chart, lv_palette_main(LV_PALETTE_PINK ), LV_CHART_AXIS_PRIMARY_Y);
for (int i=0;i<EEG_POINTS;++i){
lv_chart_set_next_value(eeg_chart, ser_delta, 0);
lv_chart_set_next_value(eeg_chart, ser_theta, 0);
lv_chart_set_next_value(eeg_chart, ser_alpha, 0);
lv_chart_set_next_value(eeg_chart, ser_beta, 0);
lv_chart_set_next_value(eeg_chart, ser_gamma, 0);
}
/* -------- Spectrum -------- */
spec_chart = lv_chart_create(tab_spec);
lv_obj_set_size(spec_chart, lv_pct(96), lv_pct(80));
lv_obj_align(spec_chart, LV_ALIGN_BOTTOM_MID, 0, -6);
lv_chart_set_type(spec_chart, LV_CHART_TYPE_BAR);
lv_chart_set_point_count(spec_chart, SPEC_BINS);
lv_chart_set_range(spec_chart, LV_CHART_AXIS_PRIMARY_Y, 0, 100);
lv_chart_set_div_line_count(spec_chart, 4, 4);
lv_obj_set_style_bg_opa(spec_chart, LV_OPA_0, 0);
lv_obj_set_style_border_width(spec_chart, 0, 0);
lv_obj_set_style_size(spec_chart, 6, LV_PART_ITEMS);
ser_spec = lv_chart_add_series(spec_chart, lv_palette_main(LV_PALETTE_BLUE), LV_CHART_AXIS_PRIMARY_Y);
for (int i=0;i<SPEC_BINS;++i) lv_chart_set_next_value(spec_chart, ser_spec, 0);
spec_info = lv_label_create(tab_spec); lv_label_set_text(spec_info, "Peak: -- Hz");
lv_obj_align(spec_info, LV_ALIGN_TOP_LEFT, 6, 4);
/* -------- Aurora -------- */
aurora_canvas = lv_canvas_create(tab_aurora);
aurora_w = min<int>(800*0.96f, 640); aurora_h = min<int>(480*0.80f, 360);
lv_obj_set_size(aurora_canvas, aurora_w, aurora_h);
lv_obj_align(aurora_canvas, LV_ALIGN_BOTTOM_MID, 0, -6);
{
size_t px = (size_t)aurora_w*(size_t)aurora_h;
aurora_buf = (lv_color_t*)heap_caps_malloc(px*sizeof(lv_color_t), MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT);
if (!aurora_buf){
aurora_w=480; aurora_h=240; lv_obj_set_size(aurora_canvas, aurora_w, aurora_h);
px=(size_t)aurora_w*(size_t)aurora_h;
aurora_buf = (lv_color_t*)heap_caps_malloc(px*sizeof(lv_color_t), MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT);
}
if (!aurora_buf){
aurora_w=320; aurora_h=180; lv_obj_set_size(aurora_canvas, aurora_w, aurora_h);
px=(size_t)aurora_w*(size_t)aurora_h;
aurora_buf = (lv_color_t*)heap_caps_malloc(px*sizeof(lv_color_t), MALLOC_CAP_8BIT);
}
lv_canvas_set_buffer(aurora_canvas, aurora_buf, aurora_w, aurora_h, LV_IMG_CF_TRUE_COLOR);
}
init_sinlut();
/* -------- Biofeedback -------- */
lv_obj_t* bio_row = lv_obj_create(tab_bio);
lv_obj_set_size(bio_row, lv_pct(96), lv_pct(85));
lv_obj_align(bio_row, LV_ALIGN_BOTTOM_MID, 0, -6);
lv_obj_set_style_bg_opa(bio_row, LV_OPA_0, 0);
lv_obj_set_style_border_width(bio_row, 0, 0);
lv_obj_set_flex_flow(bio_row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(bio_row, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(bio_row, 0, 0);
lv_obj_set_style_pad_column(bio_row, 14, 0);
MET_FOCUS = build_meter(bio_row, "Focus", lv_palette_main(LV_PALETTE_BLUE));
MET_MELLOW = build_meter(bio_row, "Mellow", lv_palette_main(LV_PALETTE_GREEN));
/* -------- Settings (slider + raw + IP/port) -------- */
// Brightness slider
bl_slider = lv_slider_create(tab_settings);
lv_obj_set_size(bl_slider, 300, 22);
lv_obj_align(bl_slider, LV_ALIGN_TOP_RIGHT, -20, 18);
lv_slider_set_range(bl_slider, 0, 100);
int start_pct = map(backlight_level_255, 0, 255, 0, 100);
lv_slider_set_value(bl_slider, start_pct, LV_ANIM_OFF);
bl_label = lv_label_create(tab_settings);
static char bl_txt[32]; snprintf(bl_txt, sizeof(bl_txt), "Brightness %d%%", start_pct);
lv_label_set_text(bl_label, bl_txt);
lv_obj_align_to(bl_label, bl_slider, LV_ALIGN_OUT_LEFT_MID, -12, 0);
lv_obj_add_event_cb(bl_slider, [](lv_event_t* e){
int pct = lv_slider_get_value(lv_event_get_target(e));
uint8_t lvl = (uint8_t) map(pct, 0, 100, 0, 255);
setBacklight(lvl); backlight_level_255 = lvl;
static char txt[32]; snprintf(txt, sizeof(txt), "Brightness %d%%", pct);
lv_label_set_text(bl_label, txt);
}, LV_EVENT_VALUE_CHANGED, nullptr);
// Raw bands (moved here)
band_label = lv_label_create(tab_settings);
lv_obj_set_width(band_label, lv_pct(94));
lv_label_set_long_mode(band_label, LV_LABEL_LONG_SCROLL_CIRCULAR);
lv_label_set_text(band_label, "delta: -- theta: -- alpha: -- beta: -- gamma: --");
lv_obj_set_style_bg_color(band_label, lv_color_hex(0x001018), 0);
lv_obj_set_style_bg_opa(band_label, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(band_label, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_border_width(band_label, 2, 0);
lv_obj_set_style_pad_all(band_label, 6, 0);
lv_obj_set_style_text_color(band_label, lv_color_hex(0xFFFFFF), 0);
lv_obj_align(band_label, LV_ALIGN_TOP_LEFT, 12, 60);
// IP / Port info
lbl_ip = lv_label_create(tab_settings);
lbl_port = lv_label_create(tab_settings);
lv_label_set_text(lbl_ip, "IP: --");
lv_label_set_text(lbl_port, "UDP Port: --");
lv_obj_align(lbl_ip, LV_ALIGN_BOTTOM_LEFT, 12, -16);
lv_obj_align(lbl_port, LV_ALIGN_BOTTOM_LEFT, 12, -40);
lv_obj_move_foreground(tabview);
}
/* =========================
===== LVGL task =====
========================= */
static void lvgl_task(void*){
const TickType_t period = pdMS_TO_TICKS(UI_PERIOD_MS);
TickType_t last = xTaskGetTickCount();
for(;;){
// Bands
if (q_have && eeg_chart){
q_have=false;
float fd=q_delta/100.0f, ft=q_theta/100.0f, fa=q_alpha/100.0f, fb=q_beta/100.0f, fg=q_gamma/100.0f;
ema_delta=BAND_EMA_A*fd+(1-BAND_EMA_A)*ema_delta;
ema_theta=BAND_EMA_A*ft+(1-BAND_EMA_A)*ema_theta;
ema_alpha=BAND_EMA_A*fa+(1-BAND_EMA_A)*ema_alpha;
ema_beta =BAND_EMA_A*fb+(1-BAND_EMA_A)*ema_beta;
ema_gamma=BAND_EMA_A*fg+(1-BAND_EMA_A)*ema_gamma;
float maxAbs=fmaxf(fabsf(ema_delta),fmaxf(fabsf(ema_theta),fmaxf(fabsf(ema_alpha),fmaxf(fabsf(ema_beta),fabsf(ema_gamma)))));
const float a_up=0.40f,a_down=0.10f;
if (maxAbs>band_vis_max) band_vis_max=a_up*maxAbs+(1-a_up)*band_vis_max;
else band_vis_max=a_down*maxAbs+(1-a_down)*band_vis_max;
int target=(int)lroundf(band_vis_max*100.0f*1.2f); if (target<120) target=120; if (target>1000) target=1000;
if (++range_tick>=30){
range_tick=0;
if (target>(int)(0.95f*chart_y_max) || target<(int)(0.5f*chart_y_max)){
chart_y_min=-target; chart_y_max=target; lv_chart_set_range(eeg_chart, LV_CHART_AXIS_PRIMARY_Y, chart_y_min, chart_y_max);
}
}
auto toChart=[](float v){ return (int)lroundf(v*100.0f); };
lv_chart_set_next_value(eeg_chart, ser_delta, toChart(ema_delta));
lv_chart_set_next_value(eeg_chart, ser_theta, toChart(ema_theta));
lv_chart_set_next_value(eeg_chart, ser_alpha, toChart(ema_alpha));
lv_chart_set_next_value(eeg_chart, ser_beta, toChart(ema_beta));
lv_chart_set_next_value(eeg_chart, ser_gamma, toChart(ema_gamma));
if (band_label){ char lbl[160]; snprintf(lbl,sizeof(lbl),"delta: %.3f theta: %.3f alpha: %.3f beta: %.3f gamma: %.3f",
ema_delta,ema_theta,ema_alpha,ema_beta,ema_gamma); lv_label_set_text(band_label,lbl); }
}
// Spectrum
if (q_spec_have && spec_chart){
q_spec_have=false;
for (int i=0;i<SPEC_BINS;++i) lv_chart_set_value_by_id(spec_chart, ser_spec, i, q_spec_vals[i]);
lv_chart_refresh(spec_chart);
}
// Aurora when visible
if (tabview && aurora_canvas){
if (lv_tabview_get_tab_act(tabview)==2) aurora_draw_frame();
}
// Biofeedback
if (q_bio_have){
q_bio_have=false;
if (q_focus >=0) ema_focus = BIO_EMA_A*(q_focus /100.0f)+(1.0f-BIO_EMA_A)*ema_focus;
if (q_mellow>=0) ema_mellow = BIO_EMA_A*(q_mellow/100.0f)+(1.0f-BIO_EMA_A)*ema_mellow;
meter_set_value(MET_FOCUS, (int)lroundf(ema_focus *100.0f));
meter_set_value(MET_MELLOW, (int)lroundf(ema_mellow*100.0f));
}
lv_timer_handler();
vTaskDelayUntil(&last, period);
}
}
/* =========================
===== Helpers =====
========================= */
static bool alloc_lvgl_draw_buffers_adaptive() {
const int tries[] = {160, 120, 96, 80, 64, 48, 40, 32, 24, 16, 12};
for (int lines : tries){
size_t need = (size_t)screenW * (size_t)lines * sizeof(lv_color_t);
buf1 = (lv_color_t*)heap_caps_malloc(need, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
buf2 = (lv_color_t*)heap_caps_malloc(need, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (buf1 && buf2){
draw_lines=lines; lv_disp_draw_buf_init(&draw_buf, buf1, buf2, screenW*draw_lines);
Serial.printf("LVGL draw buffer in PSRAM: %d lines (%.1f KB each)\n", draw_lines, need/1024.0f);
return true;
}
if (buf1){ heap_caps_free(buf1); buf1=nullptr; }
if (buf2){ heap_caps_free(buf2); buf2=nullptr; }
}
int lines=12; size_t need=(size_t)screenW*(size_t)lines*sizeof(lv_color_t);
buf1=(lv_color_t*)heap_caps_malloc(need,MALLOC_CAP_8BIT);
buf2=(lv_color_t*)heap_caps_malloc(need,MALLOC_CAP_8BIT);
if (buf1 && buf2){ draw_lines=lines; lv_disp_draw_buf_init(&draw_buf, buf1, buf2, screenW*draw_lines);
Serial.printf("LVGL draw buffer in DRAM: %d lines (%.1f KB each)\n", draw_lines, need/1024.0f); return true; }
Serial.println("FATAL: draw buffers alloc failed."); return false;
}
/* =========================
===== Arduino core =====
========================= */
void setup() {
Serial.begin(115200);
delay(100);
ledcSetup(BL_CH, BL_FREQ, BL_RES);
ledcAttachPin(TFT_BL_PIN, BL_CH);
setBacklight(backlight_level_255);
lcd.begin(); lcd.fillScreen(TFT_BLACK);
screenW = lcd.width(); screenH = lcd.height();
lv_init();
touch_init();
alloc_lvgl_draw_buffers_adaptive();
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res=screenW; disp_drv.ver_res=screenH;
disp_drv.flush_cb=lvgl_flush_cb; disp_drv.draw_buf=&draw_buf;
disp_drv.full_refresh=0; disp_drv.direct_mode=0;
lv_disp_drv_register(&disp_drv);
static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type=LV_INDEV_TYPE_POINTER; indev_drv.read_cb=lvgl_touch_cb;
lv_indev_drv_register(&indev_drv);
#ifdef ui_init
ui_init();
#endif
build_ui();
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
uint32_t t0=millis();
while (WiFi.status()!=WL_CONNECTED && millis()-t0<15000) delay(100);
if (WiFi.status()==WL_CONNECTED){
Udp.begin(OSC_UDP_PORT);
Serial.printf("WiFi OK. IP=%s, UDP port=%u\n", WiFi.localIP().toString().c_str(), OSC_UDP_PORT);
if (lbl_ip) { String s="IP: "+WiFi.localIP().toString(); lv_label_set_text(lbl_ip, s.c_str()); }
if (lbl_port) { char t[32]; snprintf(t,sizeof(t),"UDP Port: %u", OSC_UDP_PORT); lv_label_set_text(lbl_port, t); }
} else {
Serial.println("WiFi FAILED.");
if (lbl_ip) lv_label_set_text(lbl_ip, "IP: (not connected)");
if (lbl_port) { char t[32]; snprintf(t,sizeof(t),"UDP Port: %u", OSC_UDP_PORT); lv_label_set_text(lbl_port, t); }
}
xTaskCreatePinnedToCore(lvgl_task, "lvgl", 6144, nullptr, 1, nullptr, 1);
}
void loop() {
if (WiFi.status()==WL_CONNECTED){
int pkt=Udp.parsePacket();
if (pkt>0 && pkt<=(int)sizeof(oscBuf)){
int len=Udp.read(oscBuf,sizeof(oscBuf));
if (len>0){
// 1) Bands
float bands[5]; extract_bands_from_packet(oscBuf,len,bands);
static float last[5]={NAN,NAN,NAN,NAN,NAN};
for (int i=0;i<5;++i) if(!isnan(bands[i])) last[i]=bands[i];
const float MULT=100.0f;
q_delta = isnan(last[0])?0:(int)roundf(last[0]*MULT);
q_theta = isnan(last[1])?0:(int)roundf(last[1]*MULT);
q_alpha = isnan(last[2])?0:(int)roundf(last[2]*MULT);
q_beta = isnan(last[3])?0:(int)roundf(last[3]*MULT);
q_gamma = isnan(last[4])?0:(int)roundf(last[4]*MULT);
q_have = true;
// 2) Spectrum: get AF7 sample if present
bool got_af7=false; double af7_sample=0.0;
const uint8_t* buf=oscBuf;
auto scan_elem_af7=[&](const uint8_t* base,int blen){
char addr[128]; float v[8]; int n=parse_osc_element(base,blen,addr,sizeof(addr),v,8);
if (n>=3 && strstr(addr,"/muse/eeg")){ af7_sample=(double)v[1]; got_af7=true; }
};
if (len>=8 && memcmp(buf,"#bundle",7)==0){
int pos=16; while(pos+4<=len){ uint32_t sz=be32(buf+pos); pos+=4; if (sz==0 || pos+(int)sz>len) break; scan_elem_af7(buf+pos,(int)sz); pos+=sz; }
} else scan_elem_af7(buf,len);
if (got_af7){ fft_feed(af7_sample); fft_compute_and_queue(); }
// 3) Compute Focus & Mellow from bands (no MindMonitor /algorithm/)
float d = isnan(last[0])?0.0f:last[0];
float t = isnan(last[1])?0.0f:last[1];
float a = isnan(last[2])?0.0f:last[2];
float b = isnan(last[3])?0.0f:last[3];
float g = isnan(last[4])?0.0f:last[4];
const float K_EPS = 1e-6f;
float focus_feat = b / (a + t + K_EPS);
float mellow_feat = a / (b + 0.5f*g + K_EPS);
FOCUS_Z.update(focus_feat);
MELLOW_Z.update(mellow_feat);
int f_pct = (int)lroundf(FOCUS_Z.norm01(focus_feat) * 100.0f);
int m_pct = (int)lroundf(MELLOW_Z.norm01(mellow_feat)* 100.0f);
if (f_pct<0) f_pct=0; if (f_pct>100) f_pct=100;
if (m_pct<0) m_pct=0; if (m_pct>100) m_pct=100;
q_focus = f_pct;
q_mellow = m_pct;
q_bio_have = true;
}
}
}
delay(1);
}
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[platformio]
src_dir = src
boards_dir = .
[env:esp32-s3-devkitc-1-myboard]
platform = espressif32
board = esp32-s3-devkitc-1-myboard
framework = arduino
monitor_speed = 115200
upload_speed = 460800
platform_packages = framework-arduinoespressif32 @ https://github.com/espressif/arduino-esp32#2.0.3
build_flags =
-D LV_LVGL_H_INCLUDE_SIMPLE
-I./include
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DCORE_DEBUG_LEVEL=3
-D CONFIG_COMPILER_STACK_CHECK_MODE_STRONG=1
lib_deps =
lvgl/lvgl@8.3.11
tamctec/TAMC_GT911@^1.0.2
moononournation/GFX Library for Arduino@1.2.8
lovyan03/LovyanGFX@^1.1.12
maxpromer/PCA9557-arduino@^1.0.0
adafruit/Adafruit GFX Library@^1.11.9
kosme/arduinoFFT @ ^1.6
board_build.partitions = huge_app.csv
Comments